diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ee5ec58..472cf6c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -120,8 +120,10 @@ git checkout -B _sync2 origin/alpha && git merge --no-ff origin/beta && git push same change. Its config section arrives with the same update, and a section whose module is missing is not a missing feature — klippy refuses the whole config. - Changing `config-templates/AddOn.cfg.template`? Read the header of `scripts/addon_merge.py` first. - That file is never regenerated on a printer that already has one, so only whole `#@FEAT` blocks - reach existing machines. A change *inside* an existing block reaches nobody. + That file is never regenerated on a printer that already has one, so a new `#@FEAT` block is what + normally reaches existing machines. A change *inside* a block reaches nobody unless the template + also names that block in a `#@REVISE`, which replaces the printer's copy of it in place — read what + that costs before reaching for it. ## Version numbers diff --git a/.github/ci/check-addon-markers.py b/.github/ci/check-addon-markers.py new file mode 100755 index 0000000..6e8c4b6 --- /dev/null +++ b/.github/ci/check-addon-markers.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Every marker in AddOn.cfg.template does something, or the build stops. + +addon_merge.py guards itself well at RUN time: an unpaired #@DROP is refused, a #@RETIRE for a +feature the template still ships is ignored, a #@REVISE naming a block that is not there is skipped. +Each of those is the right thing to do on a printer -- and each is silent to whoever wrote the +marker, because the message is printed on a machine in somebody's workshop rather than in the pull +request that got it wrong. The P114 gate ran two versions out of date for months for exactly this +kind of reason: the delivery looked like it was working. + +So the same rules are checked here, where a typo is still cheap. +""" +import re +import sys +import os + +TPL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", + "config-templates", "AddOn.cfg.template") + +FEAT = re.compile(r'^#@FEAT\s+(\S+)\s*\|\s*(.*)$') +RETIRE = re.compile(r'^#@RETIRE\s+(\S+)\s*\|\s*(.*)$') +REVISE = re.compile(r'^#@REVISE\s+(\S+)\s*\|\s*(.*)$') +DROP = re.compile(r'^#@DROP\s+(.+?)\s*\|\s*(.*)$') +END = "#@ENDFEAT" +OFF = "#:off:" +SECTION = re.compile(r'^(?:' + re.escape(OFF) + r')?\[([^\]]+)\]\s*$') + + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else TPL + lines = open(path, encoding="utf-8").read().split("\n") + bad = [] + + # 1. Every block is closed, and closed before the next one opens. addon_merge refuses to touch an + # unterminated block precisely because removing it would swallow the neighbour -- which in this + # file means the two print macros. + feats, open_at, ids = {}, None, [] + for n, l in enumerate(lines, 1): + m = FEAT.match(l) + if m: + if open_at is not None: + bad.append("line %d: #@FEAT %s opens while %s is still open (a missing #@ENDFEAT)" + % (n, m.group(1), open_at[1])) + open_at = (n, m.group(1)) + ids.append(m.group(1)) + feats[m.group(1)] = [n, None] + elif l.startswith(END): + if open_at is None: + bad.append("line %d: #@ENDFEAT with no #@FEAT above it" % n) + else: + feats[open_at[1]][1] = n + open_at = None + if open_at is not None: + bad.append("line %d: #@FEAT %s is never closed" % (open_at[0], open_at[1])) + + dupes = sorted({f for f in ids if ids.count(f) > 1}) + for d in dupes: + bad.append("#@FEAT %s is declared more than once — the merge would only ever see the first" % d) + + shipped = set(feats) + + # 2. A #@RETIRE for a feature the template still ships is ignored at run time (addon_merge line + # ~231). Silently: the block stays on every printer and the author believes it went. + for n, l in enumerate(lines, 1): + m = RETIRE.match(l) + if m and m.group(1) in shipped: + bad.append("line %d: #@RETIRE %s, but the template still ships that #@FEAT — " + "the retirement would be ignored" % (n, m.group(1))) + + # 3. A #@REVISE is the mirror image: it can only act on a block the template also ships, because + # the replacement body comes from that block. + revised = set() + for n, l in enumerate(lines, 1): + m = REVISE.match(l) + if not m: + continue + revised.add(m.group(1)) + if m.group(1) not in shipped: + bad.append("line %d: #@REVISE %s, but no #@FEAT %s in this template — " + "there is nothing to revise it to" % (n, m.group(1), m.group(1))) + for f in sorted(revised & {m.group(1) for m in (RETIRE.match(l) for l in lines) if m}): + bad.append("#@REVISE and #@RETIRE both name %s — the merge would do neither" % f) + + # 4. A #@DROP must be paired with a block that hands the section back, or it is a delete + # instruction with nothing behind it. + in_block = set() + for fid, (a, b) in feats.items(): + if b is None: + continue + for l in lines[a - 1:b]: + m = SECTION.match(l) + if m: + in_block.add(m.group(1).strip()) + for n, l in enumerate(lines, 1): + m = DROP.match(l) + if m and m.group(1) not in in_block: + bad.append("line %d: #@DROP %s, but no #@FEAT block in this template declares that " + "section — the merge would refuse to remove it" % (n, m.group(1))) + + # 5. Two sections of the same name in one template is a config klippy refuses, and it would be + # delivered to every fresh install. + seen = {} + for n, l in enumerate(lines, 1): + m = SECTION.match(l) + if not m: + continue + name = m.group(1).strip() + if name in seen: + bad.append("line %d: [%s] is declared twice (first at line %d)" % (n, name, seen[name])) + seen[name] = n + + if bad: + print("AddOn.cfg.template markers:") + for b in bad: + print(" ERROR %s" % b) + return 1 + print("AddOn.cfg.template: %d features, %d revisions, every marker resolves." + % (len(shipped), len(revised))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eefb652..bbe9cce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,6 +196,12 @@ jobs: - name: Anchors and step numbers run: python3 .github/ci/check-docs.py + # A marker that resolves to nothing is refused on the printer and silent to whoever wrote it: + # the message goes to a journal in somebody's workshop, not to the pull request. The P114 gate + # ran two versions out of date for months for that kind of reason. Same rules, checked here. + - name: AddOn.cfg markers + run: python3 .github/ci/check-addon-markers.py + # The documents overlap on purpose and drift for free. This fails on text that has quietly # acquired a second owner, and -- when there is a base commit to compare against -- points out # sentences elsewhere that closely matched the wording just changed here. The second half is a diff --git a/README.html b/README.html index 08352e6..9136695 100644 --- a/README.html +++ b/README.html @@ -985,7 +985,7 @@

OrcaSlicer — multicolor cell. G30 then loads your saved phrozen mesh instantly — calibrating and saving it the first time, if none exists yet. Homing is automatic. It needs the -auto_mode2 and ams features in +auto_mode and ams features in AddOn.cfg, both on by default, and the gcode_shell_command extension.

diff --git a/README.md b/README.md index 2e4732b..01d6eaa 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ colour** — otherwise a long print changes shade partway through, and nothing w The start G-code heats the **bed straight to print temperature** and holds the **nozzle at a probe-safe 140 °C** through home and probe, so it cannot ooze onto the load cell. **`G30`** then loads your saved `phrozen` mesh instantly — calibrating and saving it the first time, if none exists yet. Homing is -automatic. It needs the `auto_mode2` and `ams` features in `AddOn.cfg`, both on by default, and the +automatic. It needs the `auto_mode` and `ams` features in `AddOn.cfg`, both on by default, and the `gcode_shell_command` extension. diff --git a/config-templates/AddOn.cfg.template b/config-templates/AddOn.cfg.template index 5a58c91..7e11b21 100644 --- a/config-templates/AddOn.cfg.template +++ b/config-templates/AddOn.cfg.template @@ -773,14 +773,20 @@ gcode: #@RETIRE ams | AMS on/off switch - the AMS is detected now, so there is nothing to declare # -# PHROZEN_TOOLCHANGE lives inside this block, and a change *inside* a block reaches fresh flashes -# and nowhere else -- the merge adds and removes whole blocks, it never rewrites one a printer -# already has. So the corrected macro ships under a new id and the old block is retired in the same -# run, under the same backup, exactly as the g30 -> g30_meshdone replacement did. The pairing is -# enforced by addon_merge, not trusted: if this block cannot be added, the retirement does not run, -# so the two print macros this block carries can never go missing. -#@RETIRE auto_mode | replaced by auto_mode2, which fixes a retract that assumed relative E -#@FEAT auto_mode2 | Orca auto AMS-mode: reads the 'ams' flag -> P0 M1/M2/M3 at print start +# PHROZEN_TOOLCHANGE lives inside this block. Blocks are the unit the merge moves, so an edit made +# inside one used to reach fresh flashes and nowhere else -- which is why this marker exists. It says: +# this block is the kit's, bring an existing printer's copy up to the version below. The owner's +# on/off toggle is carried over, a block that already matches is left alone and not even mentioned, +# and a block the owner deleted by hand is NOT put back -- that stays the seeded record's decision. +# One printer generation carried this block as `auto_mode2`, from the short-lived attempt to deliver +# the retract fix by renaming the block instead of revising it. #@REVISE cannot reach those: they have +# no `auto_mode` to revise. They get `auto_mode` through the normal add path, and this line takes the +# old copy out in the same run -- without it such a printer would end up with both, which is two +# [gcode_macro PHROZEN_TOOLCHANGE] sections and a config klippy refuses. +#@RETIRE auto_mode2 | folded back into auto_mode, which is the name it should never have lost + +#@REVISE auto_mode | the toolchange retract assumed relative E; see the M83 note in the macro +#@FEAT auto_mode | Orca auto AMS-mode: reads the 'ams' flag -> P0 M1/M2/M3 at print start # ───── Flag-driven AMS mode for OrcaSlicer (Arco Unleashed) ───── # The 'ams' flag follows the AMS device node, kept in step by [arco_tool_gate] -- nobody sets # it, and there is nothing to remember to set. This macro reads it at print start and applies diff --git a/scripts/addon_merge.py b/scripts/addon_merge.py index 196faf1..007626d 100755 --- a/scripts/addon_merge.py +++ b/scripts/addon_merge.py @@ -10,10 +10,12 @@ # template reached fresh flashes only. The startup banner and the one-time welcome dialog shipped, and # every existing printer silently did not get them, including the two testers' and the dev machine's. # -# WHAT IT WILL NOT DO. It never edits a block that is present, never reorders, and never touches a -# toggle. Only whole blocks that are absent get appended. That is why the banner was split out of the -# `beeper` feature into one of its own: a change INSIDE an existing block cannot be delivered this -# way, and pretending otherwise would mean rewriting the owner's file. +# WHAT IT WILL NOT DO. It never reorders, and it never changes a toggle. Blocks that are absent get +# appended; a block that is present is left exactly as it stands UNLESS the template names it in a +# #@REVISE, which is the one deliberate exception and is described below. That exception is recent: +# for a long time a change INSIDE an existing block could not be delivered at all, which is why the +# banner was split out of the `beeper` feature into a block of its own -- splitting was the only +# honest way round it. # # RETIRING A FEATURE. Adding was enough until a feature turned out to be wrong rather than merely # improvable -- the AMS on/off switch, which asked the owner a question the printer answers better by @@ -39,6 +41,35 @@ # being added this run. Either check failing means nothing is removed. An unpaired #@DROP is a # delete instruction sitting in a template, one typo from taking a working macro off every printer. # +# UPDATING A BLOCK IN PLACE. Splitting works when the change can be expressed as a NEW block. It does +# not when the thing to fix is a line in the middle of a macro that already ships. PHROZEN_TOOLCHANGE +# opened its retract without setting the extrusion mode, and the block that carries it carries the +# printer's two print macros as well -- so retiring it to ship a corrected copy under a new id would +# have meant taking those two off every printer for the length of one merge, and would have renamed a +# feature the owner can see for a reason that is none of their business. So the template can carry +# +# #@REVISE | why +# +# and the printer's copy of that block is replaced, in place and at the same position in the file, by +# the template's current version. This is the only operation that reaches INSIDE a block, so it is +# deliberately narrow: +# +# * It acts only on a block that is ALREADY THERE. A #@REVISE never adds and never resurrects; a +# block the owner deleted by hand stays deleted, which is still the seeded record's decision. +# * The owner's toggle is carried over. A body that was switched off comes back switched off, line +# for line the way addon_features.set_block writes it -- otherwise an update would quietly switch +# a feature back on, which is the one thing a toggle exists to prevent. +# * A block that already matches is not touched and not reported, so the marker can stay in the +# template for good instead of needing to be cleaned up a release later. +# * The #@ENDFEAT guard applies as everywhere else: an unterminated block is left alone. +# * Only the sections the newer version GAINS are checked for collision. The ones the block already +# owns are its own, and would otherwise read as a clash with itself. +# +# And it has a real cost, stated here rather than discovered: a revised block belongs to the kit, not +# to the owner. Hand edits inside it are overwritten the next time the template moves -- loudly, and +# with the previous whole file kept as .pre-merge.bak, but overwritten. Anyone wanting their own +# version should switch the feature off and put theirs outside the block. +# # A section that already sits inside a #@FEAT block is never touched by #@DROP -- the block # machinery owns it -- which is also what makes the operation idempotent once it has run. Removal is deliberately blunt and deliberately loud: whole block, reported by name, with # the owner's previous file kept. Two guards sit on it -- a block whose #@ENDFEAT is missing is left @@ -64,6 +95,7 @@ FEAT = re.compile(r'^#@FEAT\s+(\S+)\s*\|\s*(.*)$') RETIRE = re.compile(r'^#@RETIRE\s+(\S+)\s*\|\s*(.*)$') +REVISE = re.compile(r'^#@REVISE\s+(\S+)\s*\|\s*(.*)$') DROP = re.compile(r'^#@DROP\s+(.+?)\s*\|\s*(.*)$') END = "#@ENDFEAT" OFF = "#:off:" @@ -105,6 +137,62 @@ def retirements(path): return out + + +def revisions(path): + """{feature_id: reason} the template wants brought up to date IN PLACE.""" + try: + lines = open(path, encoding="utf-8").read().split("\n") + except OSError: + return {} + out = {} + for l in lines: + m = REVISE.match(l) + if m: + out[m.group(1)] = m.group(2).strip() + return out + + +def block_is_off(body): + """The owner's toggle, read the way addon_features.py writes it: the FIRST non-blank body line + between the markers decides. Nothing else in the block is consulted, so a block that is half + prefixed -- a hand edit, or an older toggle -- reads as whatever its first real line says, which + is exactly what the features tool would report and what SET_FEATURE would act on.""" + for l in body[1:]: + if l.startswith(END): + break + if l.strip(): + return l.startswith(OFF) + return False + + +def with_off(body): + """Put a block into the switched-off shape: the sentinel on every non-blank BODY line, the #@FEAT + and #@ENDFEAT lines left bare. Mirrors set_block() in addon_features.py line for line -- if the + two ever disagree, the features tool would report a state the file does not have.""" + out = [body[0]] + for l in body[1:]: + if l.startswith(END) or not l.strip() or l.startswith(OFF): + out.append(l) + else: + out.append(OFF + l) + return out + + +def body_key(body): + """A block reduced to what a revision actually cares about. The toggle is not content, and neither + is trailing whitespace -- an owner who opened AddOn.cfg in a Windows editor gets a CR on every + line, and without rstrip every one of those blocks would read as stale for good. The trailing + blank trim is for the one body that does not end at #@ENDFEAT: blocks() returns a final + unterminated block all the way to EOF. CI refuses to ship a template like that, but this function + is not the place to assume it. Equal keys mean no revision, which is what makes a #@REVISE that + has already run a silent no-op rather than a rewrite on every boot.""" + out = [l[len(OFF):] if l.startswith(OFF) else l for l in body] + out = [l.rstrip() for l in out] + while out and not out[-1]: + out.pop() + return out + def block_span(lines, fid): """(start, end_inclusive) of a complete #@FEAT block, or None if absent or unterminated.""" for i, l in enumerate(lines): @@ -225,6 +313,7 @@ def main(): want_ids = {f for f, _, _ in want} retire = retirements(tpl) + revise = revisions(tpl) drop = [] for fid in sorted(retire): if fid in want_ids: @@ -260,8 +349,40 @@ def main(): continue # absent, or already inside a block: either way not ours to touch loose.append((name, why, span)) + + # A block that is PRESENT but out of date. Adding and retiring move whole blocks around; this is + # the only operation that reaches INSIDE a printer's existing file, so it is deliberately narrow. + # It acts on a block that is already there and never on one that is not: resurrecting a block the + # owner deleted by hand is what the seeded record exists to prevent, and a #@REVISE must not walk + # around it. A block that already matches the template is left alone and not reported, so the + # marker can stay in the template for good instead of being cleaned up one release later. + skip_rev = [] + rev = [] + for fid in sorted(revise): + if fid not in have: + continue # absent: the add path and the seeded record decide, not this + if fid in {f for f, _, _ in drop}: + skip_rev.append((fid, "the template both retires and revises it — doing neither")) + continue + span = block_span(cfg_lines, fid) + if span is None: + skip_rev.append((fid, "its #@ENDFEAT is missing — left alone rather than rewriting past it")) + continue + old = cfg_lines[span[0]:span[1] + 1] + new = None + for f, _, body in want: + if f == fid: + new = body + break + if new is None: + skip_rev.append((fid, "the template revises a block it no longer ships — ignoring it")) + continue + if body_key(old) == body_key(new): + continue # already current: silent, so a run changes nothing twice + rev.append((fid, revise[fid], span, with_off(new) if block_is_off(old) else new)) + missing = [(f, d, b) for f, d, b in want if f not in have] - if not missing and not drop and not loose: + if not missing and not drop and not loose and not rev and not skip_rev: print(" AddOn.cfg already carries all %d features — nothing to do." % len(want)) return 0 @@ -276,6 +397,28 @@ def main(): for _, _, (a, b) in drop: for name in sections_in(cfg_lines[a:b + 1]): declared.pop(name, None) + + # A revision normally hands back the same sections it takes out, so it cannot collide with itself. + # It CAN collide if the newer version of the block declares a section that meanwhile exists + # somewhere else in the config directory -- then applying it would hand klippy two of the same + # section and the printer would not come back. Only the genuinely NEW names are checked; the ones + # the block already owns are its own and are not a clash. + kept_rev = [] + for fid, why, span, body in rev: + gained = sections_in(body) - sections_in(cfg_lines[span[0]:span[1] + 1]) + clash = sorted(s for s in gained if s in declared) + if clash: + skip_rev.append((fid, "its newer version would add %s (already in %s) — not revising it" + % (", ".join("[%s]" % c for c in clash), declared[clash[0]]))) + continue + kept_rev.append((fid, why, span, body)) + # The names this revision brings in are declared from now on. Without this the add loop that + # runs next would test its blocks against a map that predates the revision and could hand + # klippy the same section twice -- the one failure mode this whole check exists to stop. + for s in gained: + declared[s] = os.path.basename(cfg) + rev = kept_rev + add, skip = [], [] for fid, desc, body in missing: if fid in seeded: @@ -305,15 +448,20 @@ def replaced_elsewhere(span): gone = sections_in(cfg_lines[span[0]:span[1] + 1]) return gone and any(gone & sections_in(b) for _, _, b in want) + # A revision hands sections back exactly like an addition does, so it counts here too: retiring a + # block whose sections a REVISED block takes over is a replacement, and looking only at `add` + # would hold the retirement back and leave both copies in the file. + handed_back = [b for _, _, b in add] + [b for _, _, _, b in rev] held = [f for f, _, s in drop if replaced_elsewhere(s) and not any(sections_in(cfg_lines[s[0]:s[1] + 1]) & sections_in(b) - for _, _, b in add)] + for b in handed_back)] for f in held: skip.append((f, "the block that carries its sections is not being added this run — " "not retiring it")) drop = [d for d in drop if d[0] not in held] + skip.extend(skip_rev) for fid, why in skip: print(" SKIP %-18s %s" % (fid, why)) for fid, why, _ in drop: @@ -321,24 +469,53 @@ def replaced_elsewhere(span): for name, why, _ in loose: print(" %s %-18s %s" % ("would replace" if verb == "check" else "replacing ", name.split()[-1], why)) + for fid, why, _, _ in rev: + print(" %s %-18s %s" % ("would revise" if verb == "check" else "revising ", fid, why)) for fid, desc, _ in add: print(" %s %-18s %s" % ("would add " if verb == "check" else "adding ", fid, desc)) if verb == "check": - print(" (check only — nothing written. Run with 'apply' to merge %d, retire %d, replace %d.)" - % (len(add), len(drop), len(loose))) + print(" (check only — nothing written. Run with 'apply' to merge %d, retire %d, replace %d, " + "revise %d.)" % (len(add), len(drop), len(loose), len(rev))) return 0 - if not add and not drop and not loose: + if not add and not drop and not loose and not rev: print(" nothing to add.") return 0 shutil.copy2(cfg, cfg + ".pre-merge.bak") - kept = cfg_lines - if drop or loose: - gone = set() - for _, _, (a, b) in list(drop) + [(n, w, s) for n, w, s in loose]: - gone.update(range(a, b + 1)) - kept = [l for i, l in enumerate(cfg_lines) if i not in gone] + # ONE edit map, over the ORIGINAL line numbers. Every span in drop / loose / rev was measured + # against cfg_lines, so they are only valid together: applying them one after another would shift + # every span that comes after the first edit. Walking the file once and consulting the map keeps + # them all correct, and it is the same walk whether a span is being emptied (a removal) or + # refilled (a revision) -- which is why those two do not need separate code paths. + edit = {} + for _, _, span in drop: + edit[span[0]] = (span[1], []) + for _, _, span in loose: + edit[span[0]] = (span[1], []) + for _, _, span, body in rev: + edit[span[0]] = (span[1], body) + # Overlap would mean one edit eating another's lines, and a file nobody intended. It cannot + # happen as things stand -- blocks do not nest, and a #@DROP only ever names a section that sits + # outside every block -- so this is a check that should never fire rather than a fix for a known + # case. If it ever does, refusing is right: nothing has been written yet at this point, so the + # printer keeps the file it had. + covered = set() + for a, (b, _) in sorted(edit.items()): + span_lines = set(range(a, b + 1)) + if span_lines & covered: + print(" two edits overlap in this file — refusing to write anything.") + return 1 + covered |= span_lines + kept, i = [], 0 + while i < len(cfg_lines): + if i in edit: + end, body = edit[i] + kept.extend(body) + i = end + 1 + else: + kept.append(cfg_lines[i]) + i += 1 with open(cfg, "w", encoding="utf-8") as fh: fh.write("\n".join(kept).rstrip("\n") + "\n") for _, _, body in add: @@ -349,8 +526,8 @@ def replaced_elsewhere(span): fh.write(fid + "\n") # os.fsync on the directory as well: this runs immediately before selfupdate.sh asks for a # power-cycle, and the rootfs is mounted commit=120. See the sync in selfupdate.sh. - print(" merged %d, retired %d, replaced %d; previous file kept as %s" - % (len(add), len(drop), len(loose), os.path.basename(cfg) + ".pre-merge.bak")) + print(" merged %d, retired %d, replaced %d, revised %d; previous file kept as %s" + % (len(add), len(drop), len(loose), len(rev), os.path.basename(cfg) + ".pre-merge.bak")) print(" They take effect when the klipper SERVICE restarts.") return 0 diff --git a/scripts/apply-addon-merge.sh b/scripts/apply-addon-merge.sh index 9075728..eaf0515 100755 --- a/scripts/apply-addon-merge.sh +++ b/scripts/apply-addon-merge.sh @@ -49,9 +49,31 @@ want="$(feats "$TPL")" # forever and start python on every single boot to be told no. known="$( { feats "$CFG"; sort -u "$STATE" 2>/dev/null; } | sort -u )" missing="$(comm -23 <(printf '%s\n' "$want") <(printf '%s\n' "$known") 2>/dev/null)" -[ -n "$missing" ] || exit 0 -echo " AddOn.cfg: $(printf '%s\n' "$missing" | grep -c .) new feature(s) in the template — merging" +# A #@REVISE names a block the printer ALREADY has, so it adds no feature id and the comparison above +# cannot see it -- without this the merge would never be started for one. Compare the block itself, +# with the off-sentinel stripped so a feature the owner switched off is not mistaken for a stale one. +# POSIX character classes are avoided inside awk on purpose: mawk 1.3.3 does not have them, and this +# script is expected to keep working on a stock printer as well as an Unleashed one. +blk(){ awk -v id="$2" 'BEGIN{ re = "^#@FEAT[ \t]+" id "[ \t]*[|]" } + $0 ~ re { f=1 } + f { sub(/^#:off:/, ""); gsub(/\r/, ""); sub(/[ \t]+$/, ""); print } + f && /^#@ENDFEAT/ { exit }' "$1" 2>/dev/null; } +stale="" +for id in $(grep -oE '^#@REVISE[[:space:]]+[^[:space:]]+' "$TPL" 2>/dev/null | awk '{print $2}' | sort -u); do + here="$(blk "$CFG" "$id")" + [ -n "$here" ] || continue # not in this file: the add path and the seeded record decide + [ "$here" = "$(blk "$TPL" "$id")" ] || { stale="$id"; break; } +done + + +[ -n "$missing" ] || [ -n "$stale" ] || exit 0 + +if [ -n "$missing" ]; then + echo " AddOn.cfg: $(printf '%s\n' "$missing" | grep -c .) new feature(s) in the template — merging" +else + echo " AddOn.cfg: the template carries a newer '$stale' — merging" +fi python3 "$DIR/addon_merge.py" apply "$CFG" "$TPL" 2>&1 | sed 's/^/ /' # Never fail the unit. The '-' prefix on the ExecStartPre already covers this, but a guard that returns