Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/scripts/check_shared_blocks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Shared code check: the blocks the sprayer and the seed drill modules have in common must stay the same.

Sprayer/Sprayer_ESP32 and Seeder/Seeder_ESP32 are two programs with one origin: the Wi-Fi, the web helpers, the
settings for the network, the PGN handling and the relay driving are the same code in both. Those parts are
marked in the sources, with the same name in both programs:

// ==== shared "web-helpers": ...
... the code ...
// ==== end shared "web-helpers"

This script reads every .ino of both programs, pairs the blocks by name and fails when a block differs, is
missing from one program, is nested, or is not closed. Run it from the repository root: no arguments, no
packages, exit code 1 when something is wrong. The "Shared code" GitHub Action runs it on every pull request
that touches either program.

Licence: GNU General Public License v3 or later (Firmware/v4.5_AiO_WEB/COPYING).
SPDX-License-Identifier: GPL-3.0-or-later
"""
import difflib
import glob
import os
import re
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PROGRAMS = {"Sprayer_ESP32": "Sprayer/Sprayer_ESP32", "Seeder_ESP32": "Seeder/Seeder_ESP32"}
START = re.compile(r'^\s*// ==== shared "([^"]+)"')
END = re.compile(r'^\s*// ==== end shared "([^"]+)"\s*$')


def read_blocks(folder, errors):
"""{name: (file, first line number, lines)} of one program; problems go to errors"""
found = {}
for path in sorted(glob.glob(os.path.join(ROOT, folder, "*.ino"))):
rel = os.path.relpath(path, ROOT)
with open(path, encoding="utf-8") as f:
lines = f.read().split("\n")
open_name, open_at, body = None, 0, []
for n, line in enumerate(lines, 1):
m = START.match(line)
if m:
if open_name:
errors.append(f'{rel}:{n}: shared "{m.group(1)}" starts inside "{open_name}" (opened at line {open_at})')
continue
open_name, open_at, body = m.group(1), n, []
continue
m = END.match(line)
if m:
if not open_name:
errors.append(f'{rel}:{n}: end of shared "{m.group(1)}" without a start')
elif m.group(1) != open_name:
errors.append(f'{rel}:{n}: end of shared "{m.group(1)}" closes "{open_name}" (opened at line {open_at})')
else:
if open_name in found:
errors.append(f'{rel}:{open_at}: shared "{open_name}" is already in {found[open_name][0]}:{found[open_name][1]}')
else:
found[open_name] = (rel, open_at + 1, body)
open_name = None
continue
if open_name:
body.append(line.rstrip())
if open_name:
errors.append(f'{rel}:{open_at}: shared "{open_name}" is never closed')
return found


def main():
errors = []
blocks = {name: read_blocks(folder, errors) for name, folder in PROGRAMS.items()}
(a, blocks_a), (b, blocks_b) = blocks.items()
for name in sorted(set(blocks_a) ^ set(blocks_b)):
where = blocks_a.get(name) or blocks_b.get(name)
other = b if name in blocks_a else a
errors.append(f'{where[0]}:{where[1]}: shared "{name}" has no block of that name in {other}')
same_lines = 0
for name in sorted(set(blocks_a) & set(blocks_b)):
fa, la, ba = blocks_a[name]
fb, lb, bb = blocks_b[name]
if ba == bb:
same_lines += len(ba)
continue
diff = difflib.unified_diff(ba, bb, fromfile=f"{fa}:{la}", tofile=f"{fb}:{lb}", lineterm="", n=1)
errors.append(f'shared "{name}" differs between {a} and {b}: change both the same way, or move the line out of the block\n' + "\n".join(diff))
for e in errors:
print(e)
if errors:
print(f"{len(errors)} problem(s) in the shared blocks")
return 1
n = len(set(blocks_a) & set(blocks_b))
print(f"OK: {n} shared blocks, {same_lines} lines the same in {a} and {b}")
return 0


if __name__ == "__main__":
sys.exit(main())
38 changes: 38 additions & 0 deletions .github/workflows/shared-code.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Shared code check.
# The sprayer and the seed drill modules (Sprayer/Sprayer_ESP32, Seeder/Seeder_ESP32) have the same code in the
# blocks marked `// ==== shared "name"` ... `// ==== end shared "name"`. A change to one that is not made to the
# other fails this check, so the pull request shows it before it is merged. .github/scripts/check_shared_blocks.py
# does the work (plain Python 3, no packages). It runs only when either program, the script or this file changes.
# Licence: GNU General Public License v3 or later (Firmware/v4.5_AiO_WEB/COPYING).
# SPDX-License-Identifier: GPL-3.0-or-later

name: Shared code

on:
pull_request:
paths:
- 'Sprayer/Sprayer_ESP32/**'
- 'Seeder/Seeder_ESP32/**'
- '.github/scripts/check_shared_blocks.py'
- '.github/workflows/shared-code.yml'
push:
branches: [main]
paths:
- 'Sprayer/Sprayer_ESP32/**'
- 'Seeder/Seeder_ESP32/**'
- '.github/scripts/check_shared_blocks.py'
- '.github/workflows/shared-code.yml'

permissions:
contents: read

jobs:
blocks:
name: blocks
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4

- name: Compare the shared blocks of the sprayer and the seed drill programs
run: python3 .github/scripts/check_shared_blocks.py
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ text to paste into GitHub as a raw markdown code block.
page find new versions on GitHub by those names (see the firmware `CLAUDE.md`, "Common Gotchas").
- The firmware steers a tractor: safety code (WAS check, cut-offs, Keya heartbeat, watchdog) must keep working.
- Pull requests: one topic each, a short plain description of what changes for Renato and how to test it.
- `Sprayer/Sprayer_ESP32` and `Seeder/Seeder_ESP32` have the same code in the blocks marked `// ==== shared "name"` …
`// ==== end shared "name"` (Wi-Fi, web helpers, network settings, PGN handling, relays). Change both the same way,
or move a line out of the block: `.github/scripts/check_shared_blocks.py` compares them on every pull request
(the "Shared code" check). What is only one module's (sensors, rate, the cable, the relays setting) stays outside.
- README changelogs: one short row per day (a summary of up to 240 characters, with links to the sections that
hold the details). `.github/scripts/check_changelog.py` checks them on every pull request (the "Docs" check).

Expand Down
2 changes: 1 addition & 1 deletion Firmware/simulator/sim-sprayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ function wifiPost(b) {
if (b.staPass || staSsid !== X.sta) X.staPassSet = !!b.staPass; // a new network without a password: an open one
Object.assign(X, {ap: b.apSsid, staOn: staOn, sta: staSsid, host: b.host, subnet: net, ipLast: b.ipLast === undefined ? X.ipLast : +b.ipLast});
if (b.apPass) X.pass = b.apPass;
event("Wi-Fi settings saved from the page, restarting");
event("network settings saved from the page, restarting");
restart(3000);
return S.ok({ok: true, restart: true});
}
Expand Down
7 changes: 6 additions & 1 deletion Seeder/Seeder_ESP32/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ One row per day, in short; the details are in the sections of this file (the lin

| Date | What changed |
|---|---|
| 7 Nov 2026 | **0.2.2**: add-on card (`_aowcard._tcp`) under its Wi-Fi name; `_aow-ui` and `/aow/status` gone ([Notes](#notes)). **0.2.3**: four tabs as the sprayer page's, with Firmware ([How it works](#how-it-works)). **0.2.4**: Module tab: a Module card, one Network settings card. |
| 7 Nov 2026 | **0.2.2**: add-on card (`_aowcard._tcp`) under its Wi-Fi name; `_aow-ui` and `/aow/status` gone. **0.2.3**: four tabs as the sprayer's ([How it works](#how-it-works)). **0.2.4**: Module tab: Module card, one Network settings card. Code shared with the sprayer marked ([Notes](#notes)). |
| 2 Nov 2026 | **0.2.1**: the Wi-Fi settings on the page as on the XIAO page, with a search for networks ([Setup](#setup)). |
| 28 Oct 2026 | **0.2**: runs on the [Relay24_ESP32](#hardware-relay24_esp32) board of this repository, now the default; the [KC868-A32](#hardware-kc868-a32) stays as a choice. |
| 27 Oct 2026 | **0.1**: the seed drill part of Sprayer_ESP32 0.8 in its own folder: KC868-A32, 19 sections, no sensors, no PGN 160. **0.1.1**: PGN 237 in the AiO v26 layout, the sections above 16 after it, so the [AiO module page](#what-the-aio-module-page-shows) shows them all. |
Expand Down Expand Up @@ -270,5 +270,10 @@ messages with a right checksum from source `0x7F` (the program).
announcement (`Docs/ADD_ON_CARDS.md`), both under the module's own Wi-Fi name
(`Seeder-xxxx`, "Name on the network" on the page), unique to each module. The earlier
`_aow-ui._tcp` and `GET /aow/status` (0.1 to 0.2.1) are gone.
- This program grew out of the sprayer module (`Sprayer/Sprayer_ESP32`): the Wi-Fi, the web helpers, the
network settings, the PGN handling and the relay driving are the same code in both, marked in the sources
as `// ==== shared "name"` … `// ==== end shared "name"`. A change to one of those blocks goes in both
programs: the "Shared code" check (`.github/scripts/check_shared_blocks.py`) compares them on every pull
request. The network cable, the sections above 16 and the relays setting are this program's own.
- Licence: GNU General Public License v3 or later (`Firmware/v4.5_AiO_WEB/COPYING`).
Written 2026 by Claude (Anthropic) for renatorgr.
5 changes: 5 additions & 0 deletions Seeder/Seeder_ESP32/Seeder_ESP32.ino
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ bool isRaise = false, isLower = false;
uint32_t last239Ms = 0, last229Ms = 0, lastHelloMs = 0;
bool have239 = false, have229 = false, haveHello = false;
bool linkOk = false; // a PGN 239 or 229 arrived in the last NO_DATA_TIMEOUT_MS
bool masterOn = true; // the seed drill has no master switch (the sprayer's follows its switch): always on
uint32_t count239 = 0, count229 = 0, countHello = 0, countCrcErr = 0, countOther = 0;

bool staWasUp = false, ethWasUp = false;
Expand Down Expand Up @@ -203,6 +204,7 @@ String defaultApSsid();
String moduleName();

// ---------------------------------------------------------------- small helpers
// ==== shared "pgn-checksum": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
// AgOpenGPS checksum: sum of the bytes from the 3rd up to the one before the checksum
uint8_t pgnChecksum(const uint8_t* msg, size_t length)
{
Expand All @@ -212,8 +214,10 @@ uint8_t pgnChecksum(const uint8_t* msg, size_t length)
}

bool staUp() { return cfg.staOn && WiFi.status() == WL_CONNECTED; }
// ==== end shared "pgn-checksum"
bool netUp() { return staUp() || ethUp(); } // on the tractor network, over the cable or Wi-Fi

// ==== shared "log-event": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
void logEvent(const char* fmt, ...)
{
char* b = eventLog[eventNext];
Expand All @@ -226,6 +230,7 @@ void logEvent(const char* fmt, ...)
Serial.println(b);
eventNext = (eventNext + 1) % 12;
}
// ==== end shared "log-event"

static void printStatus()
{
Expand Down
10 changes: 10 additions & 0 deletions Seeder/Seeder_ESP32/zNet.ino
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ void wifiWatch()
else if (cfg.staOn) logEvent("lost the tractor network '%s'", cfg.staSsid.c_str());
}

// ==== shared "web-helpers": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
// ---------------------------------------------------------------- web helpers
static void sendJson(const String& body, int code = 200)
{
Expand Down Expand Up @@ -142,7 +143,9 @@ static String jsonHex(uint64_t v)
else snprintf(b, sizeof(b), "\"%lx\"", (unsigned long)v);
return String(b);
}
// ==== end shared "web-helpers"

// ==== shared "request-helpers": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
// A whole number lo..hi in the request (only digits), else false
static bool argNumber(const char* name, long lo, long hi, long& out)
{
Expand Down Expand Up @@ -171,6 +174,7 @@ static bool captiveRedirect()
server.send(302, "text/plain", "");
return true;
}
// ==== end shared "request-helpers"

// ---------------------------------------------------------------- web answers
static void apiStatus()
Expand Down Expand Up @@ -210,6 +214,7 @@ static void apiStatus()
for (uint8_t i = 0; i < NUM_RELAYS; i++) { if (i) j += ","; j += String(relayFn(i)); }
j += "]}";

// ==== shared "status-log": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
j += ",\"log\":[";
bool first = true;
for (uint8_t k = 0; k < 12; k++)
Expand All @@ -222,6 +227,7 @@ static void apiStatus()
}
j += "]}";
sendJson(j);
// ==== end shared "status-log"
}

static void apiSettingsGet()
Expand All @@ -233,6 +239,7 @@ static void apiSettingsGet()
sendJson(j);
}

// ==== shared "network-settings-post": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
// "192.168.1" -> the three numbers; false when it is not three numbers 0-255
static bool parseSubnet(const String& text, uint8_t out[3])
{
Expand Down Expand Up @@ -310,6 +317,7 @@ static void apiScan()
scanState = -1;
sendJson("{\"busy\":true}");
}
// ==== end shared "network-settings-post"

// Relay 1 follows section "first", the next relays the next sections (0 = the program's pin config)
static void apiRelaysPost()
Expand All @@ -325,6 +333,7 @@ static void apiRelaysPost()
sendJson("{\"ok\":true}");
}

// ==== shared "restart-update": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
static void apiRestartPost()
{
if (!passwordOk()) return;
Expand Down Expand Up @@ -369,6 +378,7 @@ static void notFound()
if (captiveRedirect()) return;
server.send(404, "text/plain", "not found");
}
// ==== end shared "restart-update"

void webStart()
{
Expand Down
6 changes: 5 additions & 1 deletion Seeder/Seeder_ESP32/zOutputs.ino
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
// Relay24_ESP32, on a LOW PCF8574 pin on the KC868-A32.
// cfg.invertOutputs (PGN 238, byte 8 bit 0; the official module calls it isRelayActiveHigh) turns that
// round: 0 = as built, 1 = inverted.
// ==== shared "relay-level": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
// The program sends this setting at start-up and when the machine profile is loaded, so the
// "relay active high" box in its machine settings normally stays unticked.

Expand All @@ -27,6 +28,7 @@ static int relayLevel(bool on)
const bool drive = on != (cfg.invertOutputs != 0);
return (drive == (RELAY_ON_HIGH != 0)) ? HIGH : LOW;
}
// ==== end shared "relay-level"

static uint8_t raiseTimer = 0, lowerTimer = 0, lastTrigger = 0;

Expand Down Expand Up @@ -112,6 +114,7 @@ void outputsInit()
}
#endif

// ==== shared "outputs": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
// Everything off, whatever the polarity setting says (used before a restart or a program update)
void outputsAllOff()
{
Expand Down Expand Up @@ -160,7 +163,7 @@ void applyOutputs()
if (top > secTop) secTop = top;
}

const uint64_t req = linkOk ? sectionsReq : 0;
const uint64_t req = (linkOk && masterOn) ? sectionsReq : 0;
const bool other[6] = { false, // other[f - FN_OTHER]
linkOk && isLower, linkOk && isRaise,
linkOk && (tramline & 1), linkOk && (tramline & 2),
Expand All @@ -180,4 +183,5 @@ void applyOutputs()
relaysWrite(bits);
relayBits = bits;
sectionsOn = open;
// ==== end shared "outputs"
}
4 changes: 4 additions & 0 deletions Seeder/Seeder_ESP32/zPgn.ino
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ void sendSignOfLife()
sendPgn(msg, sizeof(msg), broadcastIp());
}

// ==== shared "pgn-common-cases": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
static void handlePgn(uint8_t pgn, const uint8_t* d, uint8_t dataLength, IPAddress from)
{
switch (pgn)
Expand Down Expand Up @@ -113,6 +114,7 @@ static void handlePgn(uint8_t pgn, const uint8_t* d, uint8_t dataLength, IPAddre
logEvent("machine config from the program: lift %s, raise %u s, lower %u s, outputs %s",
cfg.hydEnable ? "on" : "off", cfg.raiseTime, cfg.lowerTime, cfg.invertOutputs ? "INVERTED (relay active high ticked)" : "normal");
break;
// ==== end shared "pgn-common-cases"

case 236: // output functions, 24 entries
if (dataLength < 24) return;
Expand All @@ -123,6 +125,7 @@ static void handlePgn(uint8_t pgn, const uint8_t* d, uint8_t dataLength, IPAddre
cfg.firstSection ? " (not used: the relays follow the sections in a row)" : "");
break;

// ==== shared "pgn-subnet-receive": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
case 201: // subnet: save and restart with the new IP
if (dataLength == 5 && d[5] == 201 && d[6] == 201)
{
Expand Down Expand Up @@ -164,3 +167,4 @@ void udpPoll()
handlePgn(pgn, rx, dataLength, udp.remoteIP());
}
}
// ==== end shared "pgn-subnet-receive"
6 changes: 6 additions & 0 deletions Seeder/Seeder_ESP32/zSettings.ino
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ void settingsLoad()
{
Settings d; // the defaults
prefs.begin("seeder", true);
// ==== shared "settings-wifi-load": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
cfg.apSsid = prefs.getString("apSsid", defaultApSsid());
cfg.apPass = prefs.getString("apPass", DEFAULT_AP_PASS);
cfg.staOn = prefs.getBool("staOn", false);
Expand All @@ -34,6 +35,7 @@ void settingsLoad()
cfg.host = prefs.getString("host", DEFAULT_HOST);
if (prefs.getBytesLength("subnet") == 3) prefs.getBytes("subnet", cfg.subnet, 3);
cfg.ipLast = prefs.getUChar("ipLast", d.ipLast);
// ==== end shared "settings-wifi-load"
cfg.firstSection = prefs.getUChar("first", d.firstSection);
cfg.raiseTime = prefs.getUChar("raise", d.raiseTime);
cfg.lowerTime = prefs.getUChar("lower", d.lowerTime);
Expand All @@ -58,6 +60,7 @@ void settingsLoad()
void settingsSave()
{
prefs.begin("seeder", false);
// ==== shared "settings-wifi-save": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
prefs.putString("apSsid", cfg.apSsid);
prefs.putString("apPass", cfg.apPass);
prefs.putBool("staOn", cfg.staOn);
Expand All @@ -66,6 +69,7 @@ void settingsSave()
prefs.putString("host", cfg.host);
prefs.putBytes("subnet", cfg.subnet, 3);
prefs.putUChar("ipLast", cfg.ipLast);
// ==== end shared "settings-wifi-save"
prefs.putUChar("first", cfg.firstSection);
prefs.putUChar("raise", cfg.raiseTime);
prefs.putUChar("lower", cfg.lowerTime);
Expand All @@ -81,6 +85,7 @@ void settingsSave()

// Only the Wi-Fi settings and the host name go back to the defaults (a forgotten password); the
// program's config, the output functions, the module IP and the relays setting stay
// ==== shared "settings-reset-wifi": the same in Sprayer_ESP32 and Seeder_ESP32 (change both; check_shared_blocks.py compares them)
void settingsResetWifi()
{
cfg.apSsid = defaultApSsid();
Expand All @@ -91,3 +96,4 @@ void settingsResetWifi()
cfg.host = DEFAULT_HOST;
settingsSave();
}
// ==== end shared "settings-reset-wifi"
Loading
Loading