Skip to content
Closed
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
1 change: 1 addition & 0 deletions CODEMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ repeated here.
| `filament_resolve.cpp` | Decides what a printer is sent for a spool - temperatures, material family, Creality material id, Bambu filament id, pressure advance, name - field by field from the TigerTag+ product endpoint, the chip, the material table, a default. **Pure**: no network, no Arduino, the endpoint's answer is handed in, so it is tested on a computer with injected answers. The chip never carries a printer's material id; only the endpoint and the table do. A plain TigerTag never gets the endpoint's answer considered, even if one is passed. |
| `product_api.cpp` | Fetches the product endpoint on a task, **started when a TigerTag+ is read** (for a Creality today), not when Send is pressed; Send waits only while that fetch is inside its 3 s budget. One cached answer, by product id. It asks main.cpp to stand background links down only if the heap was short of a handshake when it started: standing them down cost nothing during the request and 800-900 ms of loop AFTER it, while they dialled back. `/api/resolve` runs the whole resolution from query parameters without a spool and without sending. |
| `tigertag_cloud.cpp` | Its network calls are on a **mixed** regime, not a uniform one. The sync and the pairing start run on their own FreeRTOS tasks; `pairPoll()` and `signInWithCustomToken()` are called straight from the main loop and stall it for about a second each. Neither pattern is the rule, so check which one a call is on before adding another. |
| `backend_klipper.cpp` | Writes through **macros that must exist on the printer** (`klipper/tigertag.cfg`): `TT_SET_LANE` is neither a Klipper nor an AFC command, so without the file every assign comes back as Moonraker's "Unknown command" in the status line. The lane layout is fixed at `extruder0`..`extruder3`. Brand, variant and the spool's full quantity are not in AFC at all; they are read from `save_variables` (`tt_tN_*`), and the percentage uses `tt_tN_total` because AFC keeps `initial_weight` at 1000 g. |

### One board, three TLS talkers - and what it actually costs

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-tigertag-head-dark.svg">
Expand Down Expand Up @@ -437,3 +438,5 @@ Third-party components keep their own licenses; see
[THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md). "TigerTag" and "TigerSpool"
are project names, not a license to imply endorsement — the terms for using them
on a product you sell are in [TRADEMARK.md](TRADEMARK.md).


5 changes: 5 additions & 0 deletions firmware/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
107 changes: 107 additions & 0 deletions firmware/README-klipper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
## Klipper printers with AFC (experimental)

> **Status: experimental.** Written against the Snapmaker backend and run on one
> setup: FlashForge Creator 5 Pro, OpenCreator, AFC 1.2.8. Other AFC printers
> have not been tried.

TigerSpool can follow a Klipper printer that uses
[AFC](https://github.com/ArmoredTurtle/AFC-Klipper-Add-On) for its filament
slots, and write a scanned TigerTag into one of them. It talks to Moonraker over
a WebSocket (`ws://<ip>:7125/websocket`, no authentication), the same transport
the Snapmaker backend uses. The printer comes down with the account like every
other brand: Tiger Studio stores it under `printers/klipper/devices`.

### What it needs

- Klipper with Moonraker, reachable on the LAN at port 7125.
- AFC with four lanes named `extruder0` to `extruder3`, one per tool (T0 to T3).
Other lane layouts are not handled yet.
- A `[save_variables]` section in the printer config.
- The printer added in Tiger Studio Manager as a **Klipper** printer.
- The macros in [`klipper/tigertag.cfg`](klipper/tigertag.cfg) on the printer.

### Printer setup

1. Copy `tigertag.cfg` into the printer's config folder.
2. Add `[include tigertag.cfg]` to `printer.cfg` and restart Klipper.
3. Check that AFC accepts the commands the macro uses, one at a time in the
console, with the printer idle:

```
SET_MATERIAL LANE=extruder0 MATERIAL=PLA
SET_COLOR LANE=extruder0 COLOR=FF0000
SET_WEIGHT LANE=extruder0 WEIGHT=800
AFC_SET_SPOOL_TEMP LANE=extruder0 EXTRUDER_TEMP=210 BED_TEMP=60
```

`AFC_SET_SPOOL_TEMP` is the command the "Set spool" button in Fluidd's lane
dialog sends.
4. Then try the macro itself:

```
TT_SET_LANE TOOL=0 MATERIAL=PLA COLOR=FF0000 TEMP=210 BEDTEMP=60 WEIGHT=250 INITIAL=300 BRAND=Test
```

The console answers with every parameter it received. Open
`http://<printer-ip>:7125/printer/objects/query?AFC_lane%20extruder0` to see
what the lane now holds.

### What the spool shows

One row of four slots, labelled T0 to T3, read from Moonraker every five
seconds:

| Shown | Comes from |
| --- | --- |
| Material and variant ("PLA Silk") | `AFC_lane extruderN.material`, variant from `save_variables` |
| Colour | `AFC_lane extruderN.color` |
| Brand | `save_variables` (`tt_tN_brand`), else AFC's `spool_vendor` |
| Remaining | `weight` divided by the spool's full quantity (`tt_tN_total`), else AFC's `initial_weight` |
| Active tool | `AFC.current_load` |

### What a scan writes

Assigning a tag to a slot sends one command:

```
TT_SET_LANE TOOL=n MATERIAL=PLA SUBTYPE=Silk COLOR=RRGGBB BRAND=eSUN TEMP=210 BEDTEMP=60 WEIGHT=232 INITIAL=300
```

- `MATERIAL` is the first word of the tag's material, `SUBTYPE` the rest, the
way the Snapmaker backend splits them.
- `TEMP` and `BEDTEMP` are the middle of the tag's nozzle and bed ranges. The
macro passes both to `AFC_SET_SPOOL_TEMP` and refuses values above the
heaters' `max_temp`.
- `WEIGHT` is the tag's remaining quantity (`available`), or its full quantity
(`measure`) when no scale has written one. `INITIAL` is the full quantity.
Both are sent only when the tag's unit is grams or kilograms.
- Afterwards the spool reads the lane back. The status line says **confirmed**
when the colour arrived, **sent, not confirmed** after twelve seconds if it did
not, or shows Moonraker's own error text if the command was refused.

### Limits

- A slot is not written while the printer is printing or paused.
- The weight is whatever the chip carries. A spool whose chip has not been
updated since it was written shows that value. Tiger Studio's inventory in the
account is not read.
- AFC keeps `initial_weight` at 1000 g. Fluidd draws its spool graphic from it,
so a 300 g spool looks about a quarter full there when it is full. The spool's
own percentage uses the real full quantity and is not affected.
- Brand, variant, full quantity and tag id live in `save_variables`, not in AFC.
AFC itself does not see them.
- Spoolman is not involved.
- Memory: charged as 5 load slots, the same as a Snapmaker. That figure is
borrowed, not measured on this device.

### Files

| File | Change |
| --- | --- |
| `src/backend_klipper.h`, `src/backend_klipper.cpp` | new backend |
| `src/printer.h` | `PT_KLIPPER = 7` |
| `src/printer_budget.h` | load slots for `PT_KLIPPER` |
| `src/main.cpp` | backend creation, port, link cost, brand name |
| `src/tigertag_cloud.cpp` | `klipper` brand in the account import |
| `src/webcfg.cpp` | "Klipper" in the brand list of the web page |
| `klipper/tigertag.cfg` | printer-side macros (`TT_SET_LANE`, `TT_LOAD`) |
51 changes: 51 additions & 0 deletions firmware/patch_cloud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# patch_cloud.py - traegt PT_KLIPPER in src/tigertag_cloud.cpp und src/webcfg.cpp ein.
# Aufruf im Ordner firmware: python patch_cloud.py
# Jede Stelle muss genau einmal gefunden werden, sonst bricht das Skript ab und
# schreibt NICHTS (auch nicht in die andere Datei).
import sys

def load(path):
raw = open(path, "rb").read().decode("utf-8")
return raw, ("\r\n" if "\r\n" in raw else "\n")

def replace_once(text, old, new, what):
n = text.count(old)
if n != 1:
sys.exit("ABBRUCH: '%s' wurde %d mal gefunden (erwartet: 1). Nichts geaendert." % (what, n))
return text.replace(old, new)

def insert_after_line(text, nl, needle, new_line, what):
lines = text.split(nl)
hits = [i for i, l in enumerate(lines) if needle in l]
if len(hits) != 1:
sys.exit("ABBRUCH: '%s' wurde %d mal gefunden (erwartet: 1). Nichts geaendert." % (what, len(hits)))
i = hits[0]
ind = lines[i][:len(lines[i]) - len(lines[i].lstrip())]
lines.insert(i + 1, ind + new_line)
return nl.join(lines)

# ---- tigertag_cloud.cpp
cp = "src/tigertag_cloud.cpp"
c, nl = load(cp)
c = replace_once(c, '"snapmaker", "elegoo", "anycubic" };',
'"snapmaker", "elegoo", "anycubic", "klipper" };', "BRANDS-Liste")
c = replace_once(c, 'PT_SNAPMAKER, PT_ELEGOO, PT_ANYCUBIC };',
'PT_SNAPMAKER, PT_ELEGOO, PT_ANYCUBIC, PT_KLIPPER };', "BRAND_TYPE-Liste")
c = insert_after_line(c, nl, 'if (brand == "snapmaker")',
'if (brand == "klipper") return PT_KLIPPER; // Moonraker ws :7125, AFC',
"mapType snapmaker")

# ---- webcfg.cpp
wp = "src/webcfg.cpp"
w, wnl = load(wp)
w = replace_once(w, "'Elegoo','Anycubic'],", "'Elegoo','Anycubic','Klipper'],", "Markenliste im Web-Skript")

# Erst nach erfolgreichen Pruefungen schreiben
open(cp, "wb").write(c.encode("utf-8"))
open(wp, "wb").write(w.encode("utf-8"))
print("OK: tigertag_cloud.cpp und webcfg.cpp angepasst.")
print()
print("Zur Kontrolle, Stellen die von der Markenzahl abhaengen koennten:")
for i, l in enumerate(c.split(nl), 1):
if "okBrands" in l or "BRAND_TYPE" in l or "BRANDS[" in l or "sizeof(BRANDS" in l:
print(" tigertag_cloud.cpp:%d: %s" % (i, l.strip()))
50 changes: 50 additions & 0 deletions firmware/patch_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# patch_main.py - traegt PT_KLIPPER in src/main.cpp ein.
# Aufruf im Ordner firmware: python patch_main.py
# Es aendert nur src/main.cpp. Jede Stelle muss genau einmal gefunden werden,
# sonst bricht das Skript ab und schreibt NICHTS.
import sys

PATH = "src/main.cpp"
raw = open(PATH, "rb").read().decode("utf-8")
nl = "\r\n" if "\r\n" in raw else "\n"
lines = raw.split(nl)

def find_one(pred, what):
hits = [i for i, l in enumerate(lines) if pred(l)]
if len(hits) != 1:
sys.exit("ABBRUCH: '%s' wurde %d mal gefunden (erwartet: 1). Nichts geaendert." % (what, len(hits)))
return hits[0]

def indent_of(l):
return l[:len(l) - len(l.lstrip())]

edits = [] # (index, kind, payload)

# 1. Include
i = find_one(lambda l: l.strip() == '#include "backend_snapmaker.h"', 'include backend_snapmaker.h')
edits.append((i, "after", '#include "backend_klipper.h"'))

# 2. typeTag
i = find_one(lambda l: 't == PT_SNAPMAKER ? "Snap" : "--";' in l, 'typeTag')
edits.append((i, "replace", lines[i].replace('t == PT_SNAPMAKER ? "Snap" : "--";',
't == PT_SNAPMAKER ? "Snap" : t == PT_KLIPPER ? "Klip" : "--";')))

# 3..6. switch-Zeilen
def after_case(anchor, newline_text, what):
i = find_one(lambda l: l.strip() == anchor, what)
edits.append((i, "after", indent_of(lines[i]) + newline_text))

after_case("case PT_SNAPMAKER: return 7125;", "case PT_KLIPPER: return 7125;", "ctrlPort")
after_case("case PT_SNAPMAKER: return new SnapmakerBackend();", "case PT_KLIPPER: return new KlipperBackend();", "newBackend")
after_case("case PT_SNAPMAKER: return 8000;", "case PT_KLIPPER: return 8000;", "linkCost")
after_case('case PT_SNAPMAKER: return "snapmaker";', 'case PT_KLIPPER: return "klipper";', "brandName")

# Anwenden von hinten nach vorn, damit die Indizes stimmen
for i, kind, payload in sorted(edits, key=lambda e: -e[0]):
if kind == "after":
lines.insert(i + 1, payload)
else:
lines[i] = payload

open(PATH, "wb").write(nl.join(lines).encode("utf-8"))
print("OK: %d Stellen in %s angepasst (Zeilenende: %s)" % (len(edits), PATH, "CRLF" if nl == "\r\n" else "LF"))
Loading