Compare commits
26 Commits
nightly-0.
...
nightly
| Author | SHA1 | Date | |
|---|---|---|---|
| c1a3b9238d | |||
| aad3833301 | |||
| a2f658e701 | |||
| ab2cf6e4ed | |||
| 1b05362c2b | |||
| cfe70430d3 | |||
| 2c8a62f130 | |||
| e4b0716330 | |||
| d9fcc15c53 | |||
| 31dcf4c8fd | |||
| 319a8d5ccb | |||
| 700459085b | |||
| f93c07a971 | |||
| 81906cfffc | |||
| 3f915b058b | |||
| 8b66172ca1 | |||
| efde35130b | |||
| a31e01d28c | |||
| cec7cb2a5a | |||
| 7a43698ecc | |||
| 6b12bfb321 | |||
| 823cbfe1a9 | |||
| ce416f3b9a | |||
| 67c013f4ff | |||
| 40f85b1eb6 | |||
| 54ce101f99 |
12
.gitea/make_release_json.py
Normal file
12
.gitea/make_release_json.py
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys, json
|
||||
tag, version, body_file = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
body = open(body_file).read()
|
||||
payload = json.dumps({
|
||||
"tag_name": tag,
|
||||
"name": "KX-Bridge " + version + " Nightly",
|
||||
"body": body,
|
||||
"draft": False,
|
||||
"prerelease": True
|
||||
})
|
||||
open("/tmp/release_body.json", "w").write(payload)
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- 'requirements.txt'
|
||||
- 'web/**'
|
||||
- 'data/**'
|
||||
- '.gitea/workflows/nightly.yml'
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -21,11 +22,11 @@ jobs:
|
||||
- name: Checkout
|
||||
run: |
|
||||
if [ -d .git ]; then
|
||||
git fetch origin nightly
|
||||
git fetch --tags origin nightly
|
||||
git reset --hard origin/nightly
|
||||
git clean -fd
|
||||
else
|
||||
git clone --depth=1 --branch nightly https://gitea.it-drui.de/viewit/KX-Bridge-Release.git .
|
||||
git clone --branch nightly https://gitea.it-drui.de/viewit/KX-Bridge-Release.git .
|
||||
fi
|
||||
|
||||
- name: Install Docker CLI
|
||||
@@ -64,9 +65,34 @@ jobs:
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | \
|
||||
docker login gitea.it-drui.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Compute nightly version
|
||||
run: |
|
||||
# Letzten Stable-Tag ermitteln (v0.9.27 → minor=27)
|
||||
LAST_STABLE=$(git tag --list 'v*' --sort=-version:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | { read -r line; echo "$line"; cat >/dev/null; } || true)
|
||||
if [ -z "$LAST_STABLE" ]; then
|
||||
echo "ERROR: kein Stable-Tag gefunden" >&2; exit 1
|
||||
fi
|
||||
# Nächste Minor-Version: v0.9.27 → 0.9.28
|
||||
MAJOR=$(echo "$LAST_STABLE" | sed 's/^v//' | cut -d. -f1)
|
||||
MINOR=$(echo "$LAST_STABLE" | sed 's/^v//' | cut -d. -f2)
|
||||
PATCH=$(echo "$LAST_STABLE" | sed 's/^v//' | cut -d. -f3)
|
||||
NEXT_PATCH=$((PATCH + 1))
|
||||
BASE="${MAJOR}.${MINOR}.${NEXT_PATCH}"
|
||||
# Laufende Nummer: Anzahl vorhandener nightly-<BASE>-nightlyX Tags + 1
|
||||
COUNT=$(git tag --list "nightly-${BASE}-nightly*" | wc -l | tr -d ' ')
|
||||
N=$((COUNT + 1))
|
||||
VERSION="${BASE}-nightly${N}"
|
||||
echo "VERSION=${VERSION}" > /tmp/nightly_version.env
|
||||
echo "BASE=${BASE}" >> /tmp/nightly_version.env
|
||||
echo "LAST_STABLE=${LAST_STABLE}" >> /tmp/nightly_version.env
|
||||
echo "Computed nightly version: ${VERSION} (after ${LAST_STABLE})"
|
||||
|
||||
- name: Build & push (amd64 + arm64)
|
||||
run: |
|
||||
VERSION=$(cat VERSION)
|
||||
. /tmp/nightly_version.env
|
||||
# VERSION-Datei im Arbeitsverzeichnis für den Docker-Build setzen (kein Commit)
|
||||
echo "$VERSION" > VERSION
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
@@ -80,66 +106,61 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(cat VERSION)
|
||||
. /tmp/nightly_version.env
|
||||
TAG="nightly-${VERSION}"
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@it-drui.de"
|
||||
|
||||
# Letzten stabilen Release-Tag finden (v* ohne nightly) als Changelog-Basis
|
||||
git fetch --tags origin 2>/dev/null || true
|
||||
PREV_TAG=$(git tag | grep -E '^v[0-9]+\.' | sort -V | tail -1)
|
||||
# Fallback: letzter nightly-Tag
|
||||
[ -z "$PREV_TAG" ] && PREV_TAG=$(git tag | grep '^nightly-' | sort | tail -1)
|
||||
# Letzten Stable-Tag als Changelog-Basis (nur echte vX.Y.Z-Tags)
|
||||
PREV_TAG=$(git tag --list 'v*' --sort=-version:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| { read -r line; echo "$line"; cat >/dev/null; } || true)
|
||||
[ -z "$PREV_TAG" ] && PREV_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
|
||||
# Commits seit letztem stabilen Tag — chore/ci-Commits herausfiltern
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
COMMITS=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s" --no-merges 2>/dev/null \
|
||||
| grep -v '^- chore: nightly' \
|
||||
| grep -v '^- ci:' \
|
||||
|| true)
|
||||
else
|
||||
COMMITS=$(git log --pretty=format:"- %s" --no-merges -30 2>/dev/null \
|
||||
| grep -v '^- chore: nightly' \
|
||||
| grep -v '^- ci:' \
|
||||
|| true)
|
||||
fi
|
||||
[ -z "$COMMITS" ] && COMMITS="- Automatischer Nightly-Build (keine neuen Features seit ${PREV_TAG})"
|
||||
|
||||
# Body in Temp-Datei (vermeidet YAML-Probleme mit Sonderzeichen wie > oder ```)
|
||||
# Changelog: NIGHTLY_CHANGELOG.md hat Vorrang (manuell gepflegt),
|
||||
# sonst auto-generiert aus feat/fix-Commits seit letztem Stable-Tag
|
||||
BODY_FILE=$(mktemp)
|
||||
printf '## KX-Bridge %s -- Nightly Build\n\n' "$VERSION" > "$BODY_FILE"
|
||||
printf '[experimentell] Ungetestete Features, nur fuer Tester geeignet.\n\n' >> "$BODY_FILE"
|
||||
printf '### Aenderungen seit `%s`\n\n' "${PREV_TAG:-erstem Commit}" >> "$BODY_FILE"
|
||||
printf '%s\n\n---\n\n' "$COMMITS" >> "$BODY_FILE"
|
||||
printf '### Docker-Image aktualisieren\n\n```bash\ndocker compose pull && docker compose up -d\n```\n\n' >> "$BODY_FILE"
|
||||
printf 'Image-Tag: `gitea.it-drui.de/viewit/kx-bridge:nightly`\n' >> "$BODY_FILE"
|
||||
|
||||
# Tag setzen
|
||||
git tag -f "$TAG"
|
||||
git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git "$TAG" --force
|
||||
|
||||
# curl installieren (BusyBox wget kann kein DELETE/POST mit Headers)
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
if ! apk add --no-cache curl 2>/dev/null; then
|
||||
wget -qO /usr/local/bin/curl \
|
||||
"https://github.com/moparisthebest/static-curl/releases/download/v8.6.0/curl-amd64"
|
||||
chmod +x /usr/local/bin/curl
|
||||
printf '## KX-Bridge %s — Nightly Build\n\n' "$VERSION" > "$BODY_FILE"
|
||||
printf '[experimental] Untested features, for testers only.\n\n' >> "$BODY_FILE"
|
||||
if [ -s NIGHTLY_CHANGELOG.md ]; then
|
||||
cat NIGHTLY_CHANGELOG.md >> "$BODY_FILE"
|
||||
else
|
||||
printf '### Changes since `%s`\n\n' "$PREV_TAG" >> "$BODY_FILE"
|
||||
git log "${PREV_TAG}..HEAD" --pretty=format:'%s' --no-merges \
|
||||
| grep -E '^(feat|fix)[:(]' \
|
||||
| grep -Ev '^(feat|fix)\((ci|release|build|workflow)\)' \
|
||||
| sed 's/^/- /' \
|
||||
>> "$BODY_FILE" || true
|
||||
if ! grep -q '^- ' "$BODY_FILE"; then
|
||||
printf '- No user-facing changes in this build\n' >> "$BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
printf '\n\n---\n\n### Update Docker image\n\n```bash\ndocker compose pull && docker compose up -d\n```\n\n' >> "$BODY_FILE"
|
||||
printf 'Image tag: `gitea.it-drui.de/viewit/kx-bridge:nightly`\n' >> "$BODY_FILE"
|
||||
|
||||
# Altes Release loeschen falls vorhanden
|
||||
# Tag setzen
|
||||
git tag "$TAG"
|
||||
git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git "$TAG"
|
||||
|
||||
# curl installieren falls nötig
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
apk add --no-cache curl 2>/dev/null || \
|
||||
{ wget -qO /usr/local/bin/curl \
|
||||
"https://github.com/moparisthebest/static-curl/releases/download/v8.6.0/curl-amd64" \
|
||||
&& chmod +x /usr/local/bin/curl; }
|
||||
fi
|
||||
|
||||
# Altes Release löschen falls vorhanden
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases/tags/${TAG}" \
|
||||
2>/dev/null || true
|
||||
|
||||
# Release erstellen (JSON-Body via awk escapen)
|
||||
BODY_JSON=$(awk '{
|
||||
gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t");
|
||||
printf "%s\\n", $0
|
||||
}' "$BODY_FILE" | awk 'BEGIN{printf "\""} {printf "%s", $0} END{printf "\""}')
|
||||
JSON_PAYLOAD="{\"tag_name\":\"${TAG}\",\"name\":\"KX-Bridge ${VERSION} Nightly\",\"body\":${BODY_JSON},\"draft\":false,\"prerelease\":true}"
|
||||
printf '%s' "$JSON_PAYLOAD" > /tmp/release_body.json
|
||||
# Release erstellen — JSON sicher via jq bauen
|
||||
jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg name "KX-Bridge ${VERSION} Nightly" \
|
||||
--rawfile body "$BODY_FILE" \
|
||||
'{"tag_name":$tag,"name":$name,"body":$body,"draft":false,"prerelease":true}' \
|
||||
> /tmp/release_body.json
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Stable Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -56,9 +56,10 @@ jobs:
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | \
|
||||
docker login gitea.it-drui.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
# Strip fuehrendes 'v' fuer den Image-Tag (VERSION-Datei hat kein 'v').
|
||||
- name: Build & push (amd64 + arm64)
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
@@ -68,20 +69,6 @@ jobs:
|
||||
-t "gitea.it-drui.de/viewit/kx-bridge:${VERSION}" \
|
||||
.
|
||||
|
||||
- name: Create Gitea Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
CHANGELOG=$(awk "/^## \[${VERSION}\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md || echo "")
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases" \
|
||||
-d "{
|
||||
\"tag_name\": \"${VERSION}\",
|
||||
\"name\": \"KX-Bridge ${VERSION}\",
|
||||
\"body\": $(echo "$CHANGELOG" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'),
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}"
|
||||
# Hinweis: Das Gitea-Release (inkl. englischem Auto-Changelog + Binaries als
|
||||
# Assets) erstellt release.sh synchron, da es die lokal via CodeBuilder
|
||||
# gebauten Binaries direkt hochlaedt. Dieser Workflow baut nur das Docker-Image.
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,9 +4,7 @@ __pycache__/
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
releases/*/kx-bridge
|
||||
releases/*/extract_credentials
|
||||
releases/*/extract_credentials.exe
|
||||
releases/
|
||||
|
||||
!kx-bridge.spec
|
||||
|
||||
@@ -18,3 +16,7 @@ data/
|
||||
|
||||
!data/orca_filaments.json
|
||||
.runner-token
|
||||
|
||||
# Dev-only Dateien — nicht ins öffentliche Repo
|
||||
CLAUDE.md
|
||||
release.sh
|
||||
|
||||
6
NIGHTLY_CHANGELOG.md
Normal file
6
NIGHTLY_CHANGELOG.md
Normal file
@@ -0,0 +1,6 @@
|
||||
## Changes in this build
|
||||
|
||||
- Unified axes control panel: XY and Z merged into one card, shared step size selector (0.1 / 1 / 5 / 10 mm) plus custom mm input field, Home XY/Z buttons placed directly below their respective pads
|
||||
- Language selector moved from header bar to Settings → Appearance
|
||||
- Filament mismatch detection: Upload-and-Print is intercepted when GCode material differs from the loaded AMS slot — slot mapper dialog opens automatically to correct the assignment before printing
|
||||
- Spoolman: assign a spool per AMS slot directly in the AMS status tab (dropdown per slot kachel) and in the Filaments settings tab (dedicated assignment card)
|
||||
147
README.dev.md
Normal file
147
README.dev.md
Normal file
@@ -0,0 +1,147 @@
|
||||
<p align="center"><img src="knlogo.png" alt="KX-Bridge Logo" width="180"/></p>
|
||||
|
||||
# KX-Bridge – Dev Branch
|
||||
|
||||
> **Achtung:** Dies ist der Entwicklungs-Branch. Builds hier sind experimentell und nicht für den produktiven Einsatz geeignet.
|
||||
> Für stabile Releases → [KX-Bridge-Release](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||
|
||||
---
|
||||
|
||||
## Versionsschema
|
||||
|
||||
Dev-Builds verwenden das Format:
|
||||
|
||||
```
|
||||
<basis-version>-dev+<git-hash>
|
||||
```
|
||||
|
||||
**Beispiel:** `0.9.1-dev+04a6a20`
|
||||
|
||||
- `0.9.1` – Basis der aktuellen stabilen Version
|
||||
- `-dev` – kennzeichnet den Entwicklungs-Branch
|
||||
- `+04a6a20` – 7-stelliger Git-Commit-Hash, eindeutig je Build
|
||||
|
||||
---
|
||||
|
||||
## Dev-Binaries testen
|
||||
|
||||
Dev-Releases sind auf Gitea als Pre-Releases verfügbar:
|
||||
[Dev-Releases](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||
|
||||
### Docker (empfohlen)
|
||||
|
||||
```bash
|
||||
git clone <repo-url> -b dev
|
||||
cd kobrax
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Linux-Binary
|
||||
|
||||
```bash
|
||||
# Dev-Release herunterladen (kx-bridge-linux.zip)
|
||||
unzip kx-bridge-linux.zip
|
||||
chmod +x kx-bridge
|
||||
./kx-bridge
|
||||
```
|
||||
|
||||
`config/config.ini` und `data/` (SQLite + GCode-Store) werden **neben dem Binary**
|
||||
angelegt. Beim Erststart ohne Drucker zeigt die UI auf `http://localhost:7125` den
|
||||
Drucker-Tab mit "+ Drucker hinzufügen" — dort nur die IP eingeben, der Rest wird
|
||||
automatisch importiert.
|
||||
|
||||
### Windows-EXE
|
||||
|
||||
```
|
||||
# Dev-Release herunterladen (kx-bridge-windows.zip)
|
||||
# kx-bridge.exe starten — config/ und data/ liegen daneben
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update-Kanal
|
||||
|
||||
Dev-Versionen prüfen automatisch auf neue **Dev-Releases** — nicht auf stabile Releases.
|
||||
Im Settings-Modal → „Auf Updates prüfen" zeigt den neuesten Dev-Build an.
|
||||
|
||||
---
|
||||
|
||||
## Aktive Entwicklung (Stand 2026-05-10)
|
||||
|
||||
Stand `dev`-Branch über v0.9.7 hinaus:
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| MMU-Emulation (`/printer/objects/query?mmu`) für OrcaSlicer Filament-Sync | ✅ |
|
||||
| GCode Store (SQLite + Thumbnails) | ✅ |
|
||||
| Browser-Tab mit Suche/Filter/Sortierung | ✅ |
|
||||
| Filament-Dialog: Per-Kanal-Remapping (GCode-Kanal → AMS-Slot) | ✅ |
|
||||
| MQTT Print-Payload `ams_settings.ams_box_mapping` (nested) | ✅ |
|
||||
| Print-History in SQLite | ✅ |
|
||||
| Multi-Printer Support (Drucker-Tab + Header-Dropdown) | ✅ |
|
||||
| **Multi-Printer in einer Bridge-Instanz** (ein Prozess, N Listener) | ✅ |
|
||||
| Drucker-Emulator (`_archive/tools/kx_printer_emulator.py`) | ✅ |
|
||||
| i18n DE/EN für alle neuen UI-Elemente | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Multi-Printer-Setup
|
||||
|
||||
Eine Bridge-Instanz kann jetzt mehrere Drucker gleichzeitig verwalten — ein Prozess,
|
||||
N MQTT-Verbindungen, N HTTP-Listener, geteilte SQLite + GCode-Verzeichnis.
|
||||
|
||||
### Konfiguration
|
||||
|
||||
In `config/config.ini` pro Drucker eine `[printer_N]`-Sektion anlegen:
|
||||
|
||||
```ini
|
||||
[printer_1]
|
||||
name = Kobra X
|
||||
printer_ip = <DRUCKER_IP_1>
|
||||
mqtt_port = 9883
|
||||
username = <MQTT_USER>
|
||||
password = <MQTT_PASSWORT>
|
||||
mode_id = 20030
|
||||
device_id = <DEVICE_ID_1>
|
||||
http_port = 7125
|
||||
|
||||
[printer_2]
|
||||
name = Drucker 2
|
||||
printer_ip = <DRUCKER_IP_2>
|
||||
mqtt_port = 9883
|
||||
username = <MQTT_USER>
|
||||
password = <MQTT_PASSWORT>
|
||||
mode_id = 20030
|
||||
device_id = <DEVICE_ID_2>
|
||||
http_port = 7126
|
||||
```
|
||||
|
||||
Credentials per `extract_credentials` oder `fetch_credentials` ermitteln (siehe Haupt-README).
|
||||
|
||||
`http_port` ist optional — Default ist `7125 + (N-1)`. Wenn keine `[printer_N]`-Sektionen
|
||||
existieren, läuft die Bridge im klassischen Einzel-Modus mit `[connection]` und einem Listener.
|
||||
|
||||
### Docker
|
||||
|
||||
`docker-compose.yml` exposed jetzt einen Port-Range `7125-7130`:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "7125-7130:7125-7130"
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
# Drucker 1: http://localhost:7125
|
||||
# Drucker 2: http://localhost:7126
|
||||
```
|
||||
|
||||
OrcaSlicer / Mainsail richten den Klipper-Endpunkt pro Drucker auf den jeweiligen Port —
|
||||
keine Slicer-Anpassungen nötig.
|
||||
|
||||
---
|
||||
|
||||
## Stabile Version
|
||||
|
||||
Für den produktiven Einsatz bitte die stabile Version verwenden:
|
||||
[→ Zum stabilen Release](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||
@@ -870,6 +870,7 @@ class KobraXBridge:
|
||||
"print_speed_mode": 2,
|
||||
"connection_error": "",
|
||||
"file_ready": "",
|
||||
"filament_mismatch": None,
|
||||
"print_start_dialog": getattr(args, "print_start_dialog", 1),
|
||||
"filament_mode": "toolhead",
|
||||
"supplies_usage": 0,
|
||||
@@ -3273,6 +3274,31 @@ class KobraXBridge:
|
||||
self._state["last_upload_size"] = file_size
|
||||
|
||||
if auto_print:
|
||||
mismatch = self._check_filament_mismatch(gcode_filaments)
|
||||
if mismatch:
|
||||
log.info(f"Upload+Print blockiert — Filament-Mismatch: {mismatch}")
|
||||
self._state["file_ready"] = remote_filename
|
||||
self._state["filament_mismatch"] = mismatch
|
||||
return web.json_response({
|
||||
"done": True,
|
||||
"filament_mismatch": True,
|
||||
"mismatch_details": mismatch,
|
||||
"files": {
|
||||
"local": {
|
||||
"name": remote_filename,
|
||||
"origin": "local",
|
||||
"path": remote_filename,
|
||||
"refs": {
|
||||
"download": f"http://{request.host}/api/files/local/{remote_filename}",
|
||||
"resource": f"http://{request.host}/api/files/local/{remote_filename}",
|
||||
}
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"item": {"path": remote_filename, "root": "gcodes"},
|
||||
"action": "create_file",
|
||||
}
|
||||
}, status=201)
|
||||
log.info(f"Upload+Print (print=true): {remote_filename}")
|
||||
self._state["file_ready"] = ""
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -3301,6 +3327,45 @@ class KobraXBridge:
|
||||
}
|
||||
}, status=201)
|
||||
|
||||
def _check_filament_mismatch(self, gcode_filaments: list | None) -> list[dict] | None:
|
||||
"""Vergleicht GCode-Filamente (is_used=True) mit aktuell belegten AMS-Slots.
|
||||
|
||||
Gibt Liste von Mismatch-Einträgen zurück wenn mindestens ein genutzter
|
||||
GCode-Slot kein passendes Material im AMS hat — sonst None.
|
||||
Wird nur ausgelöst wenn AMS-Daten vorhanden sind (mindestens 1 belegter Slot)."""
|
||||
if not gcode_filaments:
|
||||
return None
|
||||
slots = self._ams_slots or []
|
||||
occupied = {s["global_index"]: s for s in slots if s.get("type") and s.get("status") == 5}
|
||||
if not occupied:
|
||||
return None
|
||||
mismatches = []
|
||||
for f in gcode_filaments:
|
||||
if not f.get("is_used"):
|
||||
continue
|
||||
idx = int(f.get("slot_index", -1))
|
||||
gcode_mat = (f.get("material") or "").upper().strip()
|
||||
if not gcode_mat:
|
||||
continue
|
||||
slot = occupied.get(idx)
|
||||
if slot is None:
|
||||
mismatches.append({
|
||||
"slot_index": idx,
|
||||
"gcode_material": gcode_mat,
|
||||
"ams_material": None,
|
||||
"reason": "empty",
|
||||
})
|
||||
else:
|
||||
ams_mat = (slot.get("type") or "").upper().strip()
|
||||
if ams_mat and ams_mat != gcode_mat:
|
||||
mismatches.append({
|
||||
"slot_index": idx,
|
||||
"gcode_material": gcode_mat,
|
||||
"ams_material": ams_mat,
|
||||
"reason": "mismatch",
|
||||
})
|
||||
return mismatches if mismatches else None
|
||||
|
||||
def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0,
|
||||
gcode_filaments: list | None = None):
|
||||
self._state["file_ready"] = ""
|
||||
@@ -3492,6 +3557,7 @@ class KobraXBridge:
|
||||
|
||||
async def handle_api_file_ready_clear(self, request):
|
||||
self._state["file_ready"] = ""
|
||||
self._state["filament_mismatch"] = None
|
||||
self._thumbnail_b64 = ""
|
||||
self._push_status_update()
|
||||
return web.json_response({"result": "ok"})
|
||||
|
||||
3
pytest.ini
Normal file
3
pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
@@ -1,3 +0,0 @@
|
||||
fb4bf06b0cfb5bcac81e2faf99d8ace1c15771ea009837802a08a4dd5ba77a8f /home/coding/Source/kobrax/releases/0.9.0-beta1/extract_credentials
|
||||
68f9bf800d1df0e71423edd35e90a8f5f7fb6e9e5220a8c12ed98cc6c4fb4833 /home/coding/Source/kobrax/releases/0.9.0-beta1/extract_credentials.exe
|
||||
7c1a99953e21fc3881f60df444940d66a4689b009e9a17ec936396857a6b9dc0 /home/coding/Source/kobrax/releases/0.9.0-beta1/kx-bridge
|
||||
68
tests/conftest.py
Normal file
68
tests/conftest.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Shared fixtures für KX-Bridge Tests.
|
||||
Startet die Bridge in-process mit einem Mock-MQTT-Client (kein Drucker nötig).
|
||||
"""
|
||||
import sys, types, argparse, pytest, pytest_asyncio
|
||||
from unittest.mock import MagicMock
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
# ── Pfad ──────────────────────────────────────────────────────────────────────
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "bridge"))
|
||||
|
||||
# ── env_loader mocken (keine .env nötig) ──────────────────────────────────────
|
||||
env_mod = types.ModuleType("env_loader")
|
||||
env_mod.PRINTER_IP = ""
|
||||
env_mod.MQTT_PORT = 9883
|
||||
env_mod.USERNAME = ""
|
||||
env_mod.PASSWORD = ""
|
||||
env_mod.MODE_ID = "20030"
|
||||
env_mod.DEVICE_ID = ""
|
||||
sys.modules["env_loader"] = env_mod
|
||||
|
||||
# ── Bridge + App importieren ───────────────────────────────────────────────────
|
||||
from kobrax_moonraker_bridge import KobraXBridge, build_app # noqa: E402
|
||||
|
||||
|
||||
def make_mock_client():
|
||||
"""Minimaler Mock-MQTT-Client — keine Verbindung, keine Threads."""
|
||||
c = MagicMock()
|
||||
c.callbacks = {}
|
||||
c.connected = False
|
||||
return c
|
||||
|
||||
|
||||
def make_args(**overrides):
|
||||
args = argparse.Namespace(
|
||||
printer_ip = "",
|
||||
mqtt_port = 9883,
|
||||
username = "",
|
||||
password = "",
|
||||
mode_id = "20030",
|
||||
device_id = "",
|
||||
host = "127.0.0.1",
|
||||
port = 7125,
|
||||
)
|
||||
for k, v in overrides.items():
|
||||
setattr(args, k, v)
|
||||
return args
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client():
|
||||
"""TestClient mit frischer Bridge-Instanz, ohne MQTT-Verbindung."""
|
||||
mock_client = make_mock_client()
|
||||
bridge = KobraXBridge(mock_client, args=make_args())
|
||||
app = build_app(bridge)
|
||||
async with TestClient(TestServer(app)) as c:
|
||||
yield c, bridge
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client_configured():
|
||||
"""TestClient mit bereits konfigurierten Zugangsdaten."""
|
||||
mock_client = make_mock_client()
|
||||
args = make_args(printer_ip="192.168.1.100", device_id="abc123deadbeef")
|
||||
bridge = KobraXBridge(mock_client, args=args)
|
||||
app = build_app(bridge)
|
||||
async with TestClient(TestServer(app)) as c:
|
||||
yield c, bridge
|
||||
3
tests/requirements-test.txt
Normal file
3
tests/requirements-test.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
pytest
|
||||
pytest-asyncio
|
||||
aiohttp
|
||||
113
tests/test_api_state.py
Normal file
113
tests/test_api_state.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Tests für /api/state — Drucker-Zustandsabfrage.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_returns_200(client):
|
||||
c, _ = client
|
||||
resp = await c.get("/api/state")
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_schema(client):
|
||||
"""Alle erwarteten Felder müssen vorhanden und typsicher sein."""
|
||||
c, _ = client
|
||||
resp = await c.get("/api/state")
|
||||
data = await resp.json()
|
||||
|
||||
assert isinstance(data["print_state"], str)
|
||||
assert isinstance(data["kobra_state"], str)
|
||||
assert isinstance(data["nozzle_temp"], float)
|
||||
assert isinstance(data["nozzle_target"], float)
|
||||
assert isinstance(data["bed_temp"], float)
|
||||
assert isinstance(data["bed_target"], float)
|
||||
assert isinstance(data["progress"], float)
|
||||
assert isinstance(data["print_duration"], int)
|
||||
assert isinstance(data["remain_time"], int)
|
||||
assert isinstance(data["curr_layer"], int)
|
||||
assert isinstance(data["total_layers"], int)
|
||||
assert isinstance(data["filename"], str)
|
||||
assert isinstance(data["fan_speed"], int)
|
||||
assert isinstance(data["light_on"], bool)
|
||||
assert isinstance(data["ams_slots"], list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_initial_values(client):
|
||||
"""Im Offline-Start müssen Temperaturen 0 und Zustand 'standby' sein."""
|
||||
c, _ = client
|
||||
data = await (await c.get("/api/state")).json()
|
||||
|
||||
assert data["print_state"] == "standby"
|
||||
assert data["nozzle_temp"] == 0.0
|
||||
assert data["bed_temp"] == 0.0
|
||||
assert data["progress"] == 0.0
|
||||
assert data["filename"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_updates_after_mqtt_print_report(client):
|
||||
"""Simuliert ein eingehendes print/report MQTT-Paket und prüft State-Update."""
|
||||
c, bridge = client
|
||||
|
||||
# Simuliere MQTT-Nachricht wie vom echten Drucker
|
||||
bridge._on_print({
|
||||
"state": "printing",
|
||||
"data": {
|
||||
"filename": "test.gcode",
|
||||
"progress": 42,
|
||||
"print_time": 10, # Minuten → 600s
|
||||
"remain_time": 5, # Minuten → 300s
|
||||
"curr_layer": 20,
|
||||
"total_layers": 100,
|
||||
}
|
||||
})
|
||||
|
||||
data = await (await c.get("/api/state")).json()
|
||||
|
||||
assert data["print_state"] == "printing"
|
||||
assert data["filename"] == "test.gcode"
|
||||
assert data["progress"] == pytest.approx(0.42)
|
||||
assert data["print_duration"] == 600
|
||||
assert data["remain_time"] == 300
|
||||
assert data["curr_layer"] == 20
|
||||
assert data["total_layers"] == 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_updates_after_mqtt_temp_report(client):
|
||||
"""Simuliert ein tempature/report Paket."""
|
||||
c, bridge = client
|
||||
|
||||
bridge._on_temp({
|
||||
"data": {
|
||||
"curr_nozzle_temp": 215.3,
|
||||
"target_nozzle_temp": 220.0,
|
||||
"curr_hotbed_temp": 59.8,
|
||||
"target_hotbed_temp": 60.0,
|
||||
}
|
||||
})
|
||||
|
||||
data = await (await c.get("/api/state")).json()
|
||||
assert data["nozzle_temp"] == pytest.approx(215.3)
|
||||
assert data["nozzle_target"] == pytest.approx(220.0)
|
||||
assert data["bed_temp"] == pytest.approx(59.8)
|
||||
assert data["bed_target"] == pytest.approx(60.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_resets_on_cancel(client):
|
||||
"""Nach 'stoped' müssen Progress und Filename zurückgesetzt werden."""
|
||||
c, bridge = client
|
||||
|
||||
# Erst Druck simulieren
|
||||
bridge._on_print({"state": "printing", "data": {"filename": "x.gcode", "progress": 50}})
|
||||
# Dann Abbruch
|
||||
bridge._on_print({"state": "stoped", "data": {}})
|
||||
|
||||
data = await (await c.get("/api/state")).json()
|
||||
assert data["progress"] == 0.0
|
||||
assert data["filename"] == ""
|
||||
109
tests/test_install.sh
Normal file
109
tests/test_install.sh
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# test_install.sh – Smoke-Test: Release-Repo klonen, start.sh ausführen, HTTP prüfen.
|
||||
#
|
||||
# Simuliert den Weg eines anonymen Nutzers:
|
||||
# 1. Release-Repo klonen
|
||||
# 2. start.sh ausführen (baut Docker-Image, startet Container)
|
||||
# 3. HTTP-Endpunkte prüfen
|
||||
# 4. Aufräumen
|
||||
#
|
||||
# Voraussetzung: Docker installiert, Port 7125 frei
|
||||
#
|
||||
# Verwendung:
|
||||
# bash tests/test_install.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="https://gitea.it-drui.de/viewit/KX-Bridge-Release"
|
||||
WORK_DIR=$(mktemp -d /tmp/kx-bridge-test-XXXXXX)
|
||||
PASS=0; FAIL=0
|
||||
|
||||
ok() { echo " ✓ $*"; PASS=$((PASS+1)); }
|
||||
fail() { echo " ✗ $*"; FAIL=$((FAIL+1)); }
|
||||
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "[cleanup] Stoppe Container und lösche Testverzeichnis ..."
|
||||
cd "$WORK_DIR/KX-Bridge-Release" 2>/dev/null && docker-compose down 2>/dev/null || true
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "=== KX-Bridge Installations-Smoke-Test ==="
|
||||
echo ""
|
||||
|
||||
# ── Schritt 1: Repo klonen ────────────────────────────────────────────────────
|
||||
echo "[1/5] Klone Release-Repo ..."
|
||||
git clone --depth=1 "$GITEA_URL" "$WORK_DIR/KX-Bridge-Release" > /dev/null 2>&1 \
|
||||
&& ok "Repo geklont" \
|
||||
|| { fail "git clone fehlgeschlagen"; exit 1; }
|
||||
|
||||
cd "$WORK_DIR/KX-Bridge-Release"
|
||||
|
||||
# ── Schritt 2: Erwartete Dateien vorhanden ────────────────────────────────────
|
||||
echo "[2/5] Prüfe Dateien im Repo ..."
|
||||
for f in start.sh docker-compose.yml Dockerfile kobrax_moonraker_bridge.py \
|
||||
anycubic_slicer.crt anycubic_slicer.key .env.example; do
|
||||
[[ -f "$f" ]] && ok "$f vorhanden" || fail "$f FEHLT"
|
||||
done
|
||||
|
||||
# Dockerfile darf keine 05_scripts/-Pfade enthalten
|
||||
if grep -q "05_scripts/" Dockerfile; then
|
||||
fail "Dockerfile enthält '05_scripts/' – falsches Dockerfile im Release-Repo!"
|
||||
else
|
||||
ok "Dockerfile Pfade korrekt (kein 05_scripts/-Präfix)"
|
||||
fi
|
||||
|
||||
# ── Schritt 3: start.sh ausführen ────────────────────────────────────────────
|
||||
echo "[3/5] Führe start.sh aus ..."
|
||||
chmod +x start.sh
|
||||
./start.sh > /tmp/kx-bridge-start.log 2>&1 \
|
||||
&& ok "start.sh erfolgreich" \
|
||||
|| { fail "start.sh fehlgeschlagen (siehe /tmp/kx-bridge-start.log)"; cat /tmp/kx-bridge-start.log; exit 1; }
|
||||
|
||||
# Kurz warten bis Bridge hochgefahren
|
||||
sleep 3
|
||||
|
||||
# ── Schritt 4: HTTP-Endpunkte prüfen ─────────────────────────────────────────
|
||||
echo "[4/5] Prüfe HTTP-Endpunkte ..."
|
||||
BASE="http://localhost:7125"
|
||||
|
||||
check_endpoint() {
|
||||
local path="$1"
|
||||
local desc="$2"
|
||||
local http_code
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE$path")
|
||||
[[ "$http_code" == "200" ]] \
|
||||
&& ok "$desc ($path → $http_code)" \
|
||||
|| fail "$desc ($path → $http_code)"
|
||||
}
|
||||
|
||||
check_endpoint "/" "Web-UI (index.html)"
|
||||
check_endpoint "/api/state" "GET /api/state"
|
||||
check_endpoint "/api/settings" "GET /api/settings"
|
||||
check_endpoint "/server/info" "GET /server/info (Moonraker)"
|
||||
check_endpoint "/printer/info" "GET /printer/info (Moonraker)"
|
||||
check_endpoint "/printer/objects/list" "GET /printer/objects/list"
|
||||
check_endpoint "/api/version" "GET /api/version (OctoPrint compat)"
|
||||
|
||||
# Beim ersten Start: printer_ip muss leer sein → Settings-Modal würde sich öffnen
|
||||
SETTINGS=$(curl -s "$BASE/api/settings")
|
||||
PRINTER_IP=$(echo "$SETTINGS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('printer_ip',''))" 2>/dev/null || echo "ERROR")
|
||||
[[ -z "$PRINTER_IP" ]] \
|
||||
&& ok "Erstkonfiguration erkannt: printer_ip leer → Settings-Modal öffnet sich" \
|
||||
|| fail "printer_ip sollte beim Erststart leer sein, ist: '$PRINTER_IP'"
|
||||
|
||||
# ── Schritt 5: Container läuft stabil ────────────────────────────────────────
|
||||
echo "[5/5] Prüfe Container-Stabilität ..."
|
||||
sleep 2
|
||||
RUNNING=$(docker-compose ps --services --filter "status=running" 2>/dev/null || true)
|
||||
[[ -n "$RUNNING" ]] \
|
||||
&& ok "Container läuft stabil" \
|
||||
|| fail "Container ist nicht mehr aktiv"
|
||||
|
||||
# ── Ergebnis ──────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "══════════════════════════════════════"
|
||||
echo " Ergebnis: $PASS bestanden, $FAIL fehlgeschlagen"
|
||||
echo "══════════════════════════════════════"
|
||||
[[ $FAIL -eq 0 ]] && exit 0 || exit 1
|
||||
77
tests/test_moonraker.py
Normal file
77
tests/test_moonraker.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Tests für Moonraker-kompatible Endpunkte die OrcaSlicer aufruft.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_info(client):
|
||||
c, _ = client
|
||||
resp = await c.get("/server/info")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["result"]["klippy_state"] in ("ready", "standby", "error")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_info(client):
|
||||
c, _ = client
|
||||
resp = await c.get("/printer/info")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert "hostname" in data["result"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_objects_list(client):
|
||||
c, _ = client
|
||||
resp = await c.get("/printer/objects/list")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
objects = data["result"]["objects"]
|
||||
# OrcaSlicer erwartet mindestens diese Objekte
|
||||
for obj in ("print_stats", "heater_bed", "extruder", "display_status"):
|
||||
assert obj in objects
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_objects_query_print_stats(client):
|
||||
c, _ = client
|
||||
resp = await c.get("/printer/objects/query?print_stats")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
ps = data["result"]["status"]["print_stats"]
|
||||
assert "state" in ps
|
||||
assert "filename" in ps
|
||||
assert "print_duration" in ps
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_objects_query_temperatures(client):
|
||||
c, _ = client
|
||||
resp = await c.get("/printer/objects/query?extruder&heater_bed")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
status = data["result"]["status"]
|
||||
assert "temperature" in status["extruder"]
|
||||
assert "temperature" in status["heater_bed"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_octoprint_version(client):
|
||||
"""OrcaSlicer probt /api/version um Drucker-Typ zu erkennen."""
|
||||
c, _ = client
|
||||
resp = await c.get("/api/version")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert "server" in data
|
||||
assert "api" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_files_list(client):
|
||||
c, _ = client
|
||||
resp = await c.get("/server/files/list")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert isinstance(data["result"], list)
|
||||
82
tests/test_settings.py
Normal file
82
tests/test_settings.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Tests für /api/settings — Lesen und Schreiben der Verbindungseinstellungen.
|
||||
"""
|
||||
import pytest
|
||||
import tempfile
|
||||
import pathlib
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_get_returns_200(client):
|
||||
c, _ = client
|
||||
resp = await c.get("/api/settings")
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_get_schema(client):
|
||||
c, _ = client
|
||||
data = await (await c.get("/api/settings")).json()
|
||||
for key in ("printer_ip", "mqtt_port", "username", "password", "device_id", "mode_id"):
|
||||
assert key in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_get_empty_when_unconfigured(client):
|
||||
"""Frische Bridge ohne Zugangsdaten → printer_ip und device_id leer."""
|
||||
c, _ = client
|
||||
data = await (await c.get("/api/settings")).json()
|
||||
assert data["printer_ip"] == ""
|
||||
assert data["device_id"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_get_returns_configured_values(client_configured):
|
||||
"""Bridge mit Zugangsdaten → Werte korrekt zurückgegeben."""
|
||||
c, _ = client_configured
|
||||
data = await (await c.get("/api/settings")).json()
|
||||
assert data["printer_ip"] == "192.168.1.100"
|
||||
assert data["device_id"] == "abc123deadbeef"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_post_writes_env(client):
|
||||
"""POST /api/settings schreibt Werte in .env-Datei."""
|
||||
c, bridge = client
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
env_path = pathlib.Path(tmpdir) / ".env"
|
||||
env_path.write_text("")
|
||||
bridge._find_env_path = lambda: env_path
|
||||
|
||||
resp = await c.post("/api/settings", json={
|
||||
"printer_ip": "10.0.0.5",
|
||||
"mqtt_port": 9883,
|
||||
"username": "userABCD",
|
||||
"password": "secret123",
|
||||
"device_id": "deadbeef01234567",
|
||||
"mode_id": "20030",
|
||||
})
|
||||
assert resp.status == 200
|
||||
|
||||
content = env_path.read_text()
|
||||
assert "PRINTER_IP=10.0.0.5" in content
|
||||
assert "MQTT_USERNAME=userABCD" in content
|
||||
assert "DEVICE_ID=deadbeef01234567" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_post_preserves_existing_keys(client):
|
||||
"""POST darf unbekannte Keys in .env nicht löschen (z.B. GITEA_TOKEN)."""
|
||||
c, bridge = client
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
env_path = pathlib.Path(tmpdir) / ".env"
|
||||
env_path.write_text("GITEA_TOKEN=mytoken\nPRINTER_IP=old\n")
|
||||
bridge._find_env_path = lambda: env_path
|
||||
|
||||
await c.post("/api/settings", json={"printer_ip": "10.0.0.99"})
|
||||
|
||||
content = env_path.read_text()
|
||||
assert "GITEA_TOKEN=mytoken" in content
|
||||
assert "PRINTER_IP=10.0.0.99" in content
|
||||
@@ -54,6 +54,13 @@ function _loadSpoolmanStatus(){
|
||||
_spoolmanStatus=d;
|
||||
_slotSpoolMap=d.slot_spools||{};
|
||||
_updateSpoolmanStatusDot();
|
||||
_buildSpoolmanSection();
|
||||
renderSpoolmanSlotCard();
|
||||
if(d.configured){
|
||||
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(sd){
|
||||
_spoolmanSpools=sd.spools||[];
|
||||
});
|
||||
}
|
||||
}).catch(function(){});
|
||||
}
|
||||
function _updateSpoolmanStatusDot(){
|
||||
@@ -78,12 +85,8 @@ function _buildSpoolmanSection(){
|
||||
if(loading)loading.style.display='';
|
||||
|
||||
var usedSlots={};
|
||||
document.querySelectorAll('#fd-slots select').forEach(function(sel){
|
||||
var idx=parseInt(sel.value);
|
||||
if(idx>=0){
|
||||
var slot=(_amsSlots||[]).find(function(s){return s.slot_index===idx;});
|
||||
if(slot&&!usedSlots[idx])usedSlots[idx]=slot;
|
||||
}
|
||||
(_amsSlots||[]).forEach(function(slot){
|
||||
usedSlots[slot.slot_index]=slot;
|
||||
});
|
||||
|
||||
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(d){
|
||||
@@ -376,12 +379,10 @@ function applyLang(){
|
||||
setText('d-chart-label',T.panel_temps_chart);
|
||||
// Axis labels
|
||||
setText('ptitle-motion-xy',T.panel_motion_xy);
|
||||
setText('ptitle-motion-z',T.panel_motion_z);
|
||||
document.querySelectorAll('.lbl-home-z').forEach(e=>e.textContent=T.btn_home_z);
|
||||
document.querySelectorAll('.lbl-home-xy').forEach(e=>e.textContent=T.btn_home_xy);
|
||||
document.querySelectorAll('.lbl-home-all').forEach(e=>e.textContent=T.btn_home_all);
|
||||
document.querySelectorAll('.lbl-disable-motors').forEach(e=>e.textContent=T.btn_disable_motors);
|
||||
document.querySelectorAll('.lbl-step').forEach(e=>e.textContent=T.label_step);
|
||||
document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':','')));
|
||||
// Console
|
||||
setText('ptitle-console',T.panel_console_title);
|
||||
@@ -550,6 +551,7 @@ function ensureAceDryCards(){
|
||||
// defer until DOM ready
|
||||
window.addEventListener('DOMContentLoaded',function(){
|
||||
setLanguage(currentLang).catch(function(){});
|
||||
_loadSpoolmanStatus();
|
||||
// Kein Drucker konfiguriert? → direkt in den Drucker-Tab (zeigt "+ Drucker hinzufügen")
|
||||
fetch('/kx/printers').then(function(r){return r.json()}).then(function(d){
|
||||
if(!d.result||!d.result.length){showPanel('printers');loadPrinterTab();}
|
||||
@@ -748,7 +750,7 @@ function applyState(){
|
||||
frb.style.display='none';
|
||||
if(!_fdDialogOpen&&!_fdUserCancelled&&_fdAutoOpenedFile!==s.file_ready){
|
||||
_fdAutoOpenedFile=s.file_ready;
|
||||
startReadyFileWithSlots(s.file_ready,true);
|
||||
startReadyFileWithSlots(s.file_ready,true,s.filament_mismatch||null);
|
||||
}
|
||||
} else {
|
||||
frb.style.display='flex';
|
||||
@@ -961,6 +963,22 @@ function applyState(){
|
||||
var tt=(profile.name||'')+(profile.id?' ('+profile.id+')':'');
|
||||
vendorBadge='<div class="slot-label" style="font-size:9px;color:var(--accent);font-weight:600;margin-top:1px" title="'+tt+'">'+profile.vendor+'</div>';
|
||||
}
|
||||
var spoolSel='';
|
||||
if(_spoolmanStatus.configured&&!empty&&_spoolmanSpools.length){
|
||||
var curSpool=_slotSpoolMap[String(globalIdx)]||'';
|
||||
var spoolOpts='<option value="">–</option>'+_spoolmanSpools.map(function(sp){
|
||||
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor.name+' ':'';
|
||||
var name=sp.filament?sp.filament.name:'#'+sp.id;
|
||||
var rem=sp.remaining_weight!=null?' '+sp.remaining_weight.toFixed(0)+'g':'';
|
||||
return '<option value="'+sp.id+'"'+(String(sp.id)==String(curSpool)?' selected':'')+'>'+
|
||||
escHtml(vendor+name+rem)+'</option>';
|
||||
}).join('');
|
||||
spoolSel='<div onclick="event.stopPropagation()" style="margin-top:6px;border-top:1px solid rgba(255,255,255,.07);padding-top:5px">'
|
||||
+'<div style="font-size:8px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px">🧵 Spoolman</div>'
|
||||
+'<select data-spool-slot="'+globalIdx+'" onchange="onAmsSpoolChange(this)" '
|
||||
+'style="width:100%;padding:3px 5px;font-size:10px;border-radius:5px;border:1px solid var(--border);background:var(--card);color:var(--txt);cursor:pointer">'+spoolOpts+'</select>'
|
||||
+'</div>';
|
||||
}
|
||||
html+='<div class="ams-slot'+(active?' active':'')+(loaded?' loaded':'')+(activity?' '+activity:'')+(empty?' empty':'')
|
||||
+'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
|
||||
+'<div class="slot-circle" style="background:'+col+'"></div>'
|
||||
@@ -968,6 +986,7 @@ function applyState(){
|
||||
+vendorBadge
|
||||
+'<div class="slot-label">'+slotLabel+'</div>'
|
||||
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
|
||||
+spoolSel
|
||||
+'<div style="font-size:9px;color:var(--txt2);margin-top:2px">✏</div>'
|
||||
+'</div>';
|
||||
});
|
||||
@@ -1074,6 +1093,7 @@ function openSettings(){
|
||||
pi.value=sec;
|
||||
}
|
||||
renderFilamentMapping(d.filament_profiles||{});
|
||||
renderSpoolmanSlotCard();
|
||||
// Spoolman
|
||||
var su=document.getElementById('s-spoolman-url');if(su)su.value=d.spoolman_server||'';
|
||||
var sr=document.getElementById('s-spoolman-sync-rate');if(sr)sr.value=(d.spoolman_sync_rate!==undefined?d.spoolman_sync_rate:30);
|
||||
@@ -1221,6 +1241,64 @@ function _vendorCheck(cb){
|
||||
var v=cb.getAttribute('data-vendor');
|
||||
if(cb.checked)_vendorChecklistSel[v]=true; else delete _vendorChecklistSel[v];
|
||||
}
|
||||
function renderSpoolmanSlotCard(){
|
||||
var card=document.getElementById('spoolman-slot-card');
|
||||
var rows=document.getElementById('spoolman-slot-rows');
|
||||
if(!card||!rows)return;
|
||||
if(!_spoolmanStatus.configured){card.style.display='none';return;}
|
||||
card.style.display='';
|
||||
Promise.all([
|
||||
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}),
|
||||
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();})
|
||||
]).then(function(res){
|
||||
var spools=res[0].spools||[];
|
||||
var slots=(res[1].result||[]).sort(function(a,b){return a.slot_index-b.slot_index;});
|
||||
if(!slots.length){rows.innerHTML='<span style="font-size:11px;color:var(--txt2)">Keine AMS-Slots bekannt.</span>';return;}
|
||||
rows.innerHTML=slots.map(function(slot){
|
||||
var idx=parseInt(slot.slot_index);
|
||||
var col=slot.color_hex||'#888';
|
||||
var mat=slot.material||'';
|
||||
var current=_slotSpoolMap[String(idx)]||'';
|
||||
var opts='<option value="">–</option>'+spools.map(function(sp){
|
||||
var rem=sp.remaining_weight!=null?' ('+sp.remaining_weight.toFixed(0)+'g)':'';
|
||||
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor.name+' ':'';
|
||||
var name=sp.filament?sp.filament.name:'Spool #'+sp.id;
|
||||
var mat2=sp.filament&&sp.filament.material?' · '+sp.filament.material:'';
|
||||
return '<option value="'+sp.id+'"'+(String(sp.id)==String(current)?' selected':'')+'>'+
|
||||
escHtml('#'+sp.id+' '+vendor+name+mat2+rem)+'</option>';
|
||||
}).join('');
|
||||
return '<div style="display:flex;align-items:center;gap:8px;font-size:12px">'+
|
||||
'<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:'+col+';border:1px solid var(--border);flex-shrink:0"></span>'+
|
||||
'<span style="color:var(--txt2);min-width:60px">Slot '+(idx+1)+' <span style="color:var(--txt2);font-size:10px">'+escHtml(mat)+'</span></span>'+
|
||||
'<select data-spool-slot="'+idx+'" style="flex:1;padding:3px 6px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);font-size:12px">'+opts+'</select></div>';
|
||||
}).join('');
|
||||
}).catch(function(){rows.innerHTML='<span style="font-size:11px;color:var(--err)">Spoolman nicht erreichbar</span>';});
|
||||
}
|
||||
|
||||
function onAmsSpoolChange(sel){
|
||||
var idx=sel.getAttribute('data-spool-slot');
|
||||
var val=sel.value;
|
||||
if(val) _slotSpoolMap[String(idx)]=parseInt(val);
|
||||
else delete _slotSpoolMap[String(idx)];
|
||||
var mapping={};
|
||||
Object.keys(_slotSpoolMap).forEach(function(k){mapping[k]=_slotSpoolMap[k];});
|
||||
fetch(_apiUrl('/kx/spoolman/active-spool'),{method:'POST',
|
||||
headers:{'Content-Type':'application/json'},body:JSON.stringify({slot_spools:mapping})});
|
||||
}
|
||||
|
||||
function saveSpoolmanSlots(){
|
||||
var mapping={};
|
||||
document.querySelectorAll('#spoolman-slot-rows select[data-spool-slot]').forEach(function(sel){
|
||||
var idx=sel.getAttribute('data-spool-slot');
|
||||
var val=sel.value;
|
||||
if(val)mapping[idx]=parseInt(val);
|
||||
});
|
||||
fetch(_apiUrl('/kx/spoolman/active-spool'),{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({slot_spools:mapping})}).then(function(r){return r.json();}).then(function(d){
|
||||
_slotSpoolMap=d.slot_spools||{};
|
||||
});
|
||||
}
|
||||
|
||||
function saveVisibleVendors(){
|
||||
var vendors=Object.keys(_vendorChecklistSel);
|
||||
fetch(_apiUrl('/kx/filament/visible_vendors'),{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
@@ -1790,7 +1868,15 @@ function setStep(btn,v){
|
||||
currentStep=v;
|
||||
document.querySelectorAll('.step-btn').forEach(b=>b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('step-display').textContent=v;
|
||||
var ci=document.getElementById('step-custom');
|
||||
if(ci)ci.value='';
|
||||
}
|
||||
function setStepCustom(inp){
|
||||
var v=parseFloat(inp.value);
|
||||
if(!isNaN(v)&&v>0){
|
||||
currentStep=v;
|
||||
document.querySelectorAll('.step-btn').forEach(b=>b.classList.remove('active'));
|
||||
}
|
||||
}
|
||||
function move(axis,dir,dist){
|
||||
// axis: 0=X,1=Y,2=Z → printer axis codes: 1=X,2=Y,3=Z
|
||||
@@ -2485,7 +2571,20 @@ function confirmStoreWebVerify(){
|
||||
});
|
||||
}
|
||||
|
||||
function startReadyFileWithSlots(filename,_autoOpen){
|
||||
function _showMismatchWarn(mismatches){
|
||||
var el=document.getElementById('fd-mismatch-warn');
|
||||
if(!el)return;
|
||||
if(!mismatches||!mismatches.length){el.style.display='none';el.innerHTML='';return;}
|
||||
var lines=mismatches.map(function(m){
|
||||
var slot='Slot '+(m.slot_index+1);
|
||||
if(m.reason==='empty')
|
||||
return '⚠ '+slot+': GCode needs '+m.gcode_material+' — slot is empty';
|
||||
return '⚠ '+slot+': GCode needs '+m.gcode_material+', loaded: '+(m.ams_material||'?');
|
||||
});
|
||||
el.innerHTML='<strong>Filament mismatch detected</strong><br>'+lines.join('<br>');
|
||||
el.style.display='';
|
||||
}
|
||||
function startReadyFileWithSlots(filename,_autoOpen,_mismatch){
|
||||
if(!_autoOpen) _fdAutoOpenedFile=null; // manueller Aufruf → Auto-Open-Sperre aufheben
|
||||
var fn=filename||S.file_ready;
|
||||
var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;});
|
||||
@@ -2505,9 +2604,11 @@ function startReadyFileWithSlots(filename,_autoOpen){
|
||||
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){
|
||||
if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;}
|
||||
openFilamentDialog(d.result||[]);
|
||||
_showMismatchWarn(_mismatch||null);
|
||||
}).catch(function(){
|
||||
if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;}
|
||||
openFilamentDialog([]);
|
||||
_showMismatchWarn(_mismatch||null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2743,6 +2844,7 @@ function openFilamentDialog(slots){
|
||||
function closeFilamentDialog(){
|
||||
var dlg=document.getElementById('filament-dialog');
|
||||
if(dlg)dlg.classList.remove('open');
|
||||
_showMismatchWarn(null);
|
||||
_fdDialogOpen=false;
|
||||
if(_fdAutoOpenedFile){
|
||||
_fdUserCancelled=true;
|
||||
|
||||
@@ -32,17 +32,6 @@
|
||||
<span id="h-version" style="font-size:11px;opacity:.5;margin-left:6px"></span>
|
||||
<div class="hbadge" id="h-badge"><span class="dot"></span><span id="h-state">Standby</span></div>
|
||||
<button class="theme-btn" onclick="toggleTheme()">☀ / ☾</button>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span aria-hidden="true" style="font-size:15px;line-height:1;opacity:.85">🌐</span>
|
||||
<select class="theme-btn" id="lang-select" onchange="setLanguageFromSelect()" style="padding:6px 10px">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Espanol</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="it">Italiano</option>
|
||||
<option value="zh-cn">中文(简体)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="theme-btn" onclick="showPanel('settings')" id="settings-btn" title="Einstellungen">⚙</button>
|
||||
<button class="conn-btn disconnected" id="conn-btn" onclick="toggleConnection()">⚡ Verbinden</button>
|
||||
</header>
|
||||
@@ -252,39 +241,51 @@
|
||||
|
||||
<!-- Achsensteuerung -->
|
||||
<div class="card">
|
||||
<div class="card-title"><span>✛</span> <span id="ptitle-motion-xy">XY-Achsen</span></div>
|
||||
<div class="joypad">
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(1,1,getStep())" title="Y+">▲</button>
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(0,-1,getStep())" title="X−">◀</button>
|
||||
<button class="joy home" onclick="homeAll()" title="Home All">⌂</button>
|
||||
<button class="joy" onclick="move(0,1,getStep())" title="X+">▶</button>
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(1,-1,getStep())" title="Y−">▼</button>
|
||||
<div></div>
|
||||
<div class="card-title"><span>✛</span> <span id="ptitle-motion-xy">Achsensteuerung</span></div>
|
||||
<div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
|
||||
<!-- XY -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:6px">
|
||||
<div class="joypad">
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(1,1,getStep())" title="Y+">▲</button>
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(0,-1,getStep())" title="X−">◀</button>
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(0,1,getStep())" title="X+">▶</button>
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(1,-1,getStep())" title="Y−">▼</button>
|
||||
<div></div>
|
||||
</div>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt);width:100%" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
|
||||
</div>
|
||||
<!-- Z -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:6px">
|
||||
<div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)">
|
||||
<button class="joy" onclick="move(2,1,getStep())" title="Z+">▲</button>
|
||||
<button class="joy" onclick="move(2,-1,getStep())" title="Z−">▼</button>
|
||||
</div>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt);width:100%" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-btns">
|
||||
<button class="step-btn" onclick="setStep(this,0.1)">0.1</button>
|
||||
<button class="step-btn active" onclick="setStep(this,1)">1</button>
|
||||
<button class="step-btn" onclick="setStep(this,5)">5</button>
|
||||
<button class="step-btn" onclick="setStep(this,10)">10 mm</button>
|
||||
<!-- Einheitliche Step-Size -->
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-top:10px;flex-wrap:wrap">
|
||||
<div class="step-btns" style="margin-top:0">
|
||||
<button class="step-btn" onclick="setStep(this,0.1)">0.1</button>
|
||||
<button class="step-btn active" onclick="setStep(this,1)">1</button>
|
||||
<button class="step-btn" onclick="setStep(this,5)">5</button>
|
||||
<button class="step-btn" onclick="setStep(this,10)">10</button>
|
||||
</div>
|
||||
<input id="step-custom" type="number" min="0.1" max="260" step="0.1"
|
||||
placeholder="mm"
|
||||
style="width:60px;padding:4px 6px;font-size:12px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);text-align:center"
|
||||
oninput="setStepCustom(this)">
|
||||
</div>
|
||||
<div class="home-btns">
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
|
||||
<!-- Globale Befehle zentriert -->
|
||||
<div style="display:flex;justify-content:center;gap:8px;margin-top:10px">
|
||||
<button class="btn btn-sm btn-accent" onclick="homeAll()"><span class="lbl-home-all">Home All</span></button>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="disableMotors()"><span class="lbl-disable-motors">Motors Off</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title"><span>↕</span> <span id="ptitle-motion-z">Z-Achse</span></div>
|
||||
<div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)">
|
||||
<button class="joy" onclick="move(2,1,getStep())" title="Z+">▲</button>
|
||||
<button class="joy" onclick="move(2,-1,getStep())" title="Z−">▼</button>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:8px;font-size:12px;color:var(--txt2)"><span class="lbl-step">Schrittweite:</span> <span id="step-display">1</span> mm</div>
|
||||
</div>
|
||||
|
||||
<!-- Print Speed -->
|
||||
<div class="card">
|
||||
@@ -570,6 +571,12 @@
|
||||
<div id="visible-vendors-list" style="max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;padding:8px"></div>
|
||||
<button class="btn btn-sm" style="background:var(--accent);color:#fff;margin-top:8px" onclick="saveVisibleVendors()"><span id="lbl-visible-vendors-save">Auswahl speichern</span></button>
|
||||
</div>
|
||||
<div class="card" id="spoolman-slot-card" style="display:none">
|
||||
<div class="card-title"><span>🧵</span> <span id="lbl-spoolman-slot-assign">Spoolman — Slot-Zuordnung</span></div>
|
||||
<div style="font-size:11px;color:var(--txt2);margin-bottom:10px" id="lbl-spoolman-slot-hint">Spoolman-Spool pro AMS-Slot zuweisen. Der Verbrauch wird beim Druck automatisch gemeldet.</div>
|
||||
<div id="spoolman-slot-rows" style="display:flex;flex-direction:column;gap:8px"></div>
|
||||
<button class="btn btn-sm" style="background:var(--accent);color:#fff;margin-top:10px" onclick="saveSpoolmanSlots()"><span id="lbl-spoolman-slot-save">Zuordnung speichern</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Integrationen -->
|
||||
@@ -655,6 +662,7 @@
|
||||
<button onclick="closeFilamentDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)">✕</button>
|
||||
</div>
|
||||
<p id="fd-slots-hint" style="font-size:12px;color:var(--txt2);margin-bottom:10px">GCode-Kanal → AMS-Slot zuweisen:</p>
|
||||
<div id="fd-mismatch-warn" style="display:none;margin-bottom:10px;padding:8px 10px;background:rgba(255,160,0,.12);border:1px solid rgba(255,160,0,.4);border-radius:8px;font-size:11px;color:#ffa000"></div>
|
||||
<div id="fd-slots" style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px"></div>
|
||||
<div id="fd-objects-section" style="display:none;margin-bottom:16px">
|
||||
<button type="button" id="fd-objects-toggle" onclick="toggleFdObjects()"
|
||||
|
||||
@@ -189,8 +189,7 @@
|
||||
"panel_extras_camera": "Kamera",
|
||||
"panel_extras_fan": "Lüfter",
|
||||
"panel_extras_light": "Licht",
|
||||
"panel_motion_xy": "XY-Achsen",
|
||||
"panel_motion_z": "Z-Achse",
|
||||
"panel_motion_xy": "Achsensteuerung",
|
||||
"panel_print_btn_cancel": "✕ Abbrechen",
|
||||
"panel_print_btn_pause": "⏸ Pause",
|
||||
"panel_print_btn_resume": "▶ Fortsetzen",
|
||||
@@ -326,4 +325,4 @@
|
||||
"update_error": "Fehler",
|
||||
"update_none": "Bereits aktuell",
|
||||
"update_restarting": "Starte neu..."
|
||||
}
|
||||
}
|
||||
@@ -189,8 +189,7 @@
|
||||
"panel_extras_camera": "Camera",
|
||||
"panel_extras_fan": "Fan",
|
||||
"panel_extras_light": "Light",
|
||||
"panel_motion_xy": "XY Axes",
|
||||
"panel_motion_z": "Z Axis",
|
||||
"panel_motion_xy": "Axes Control",
|
||||
"panel_print_btn_cancel": "✕ Cancel",
|
||||
"panel_print_btn_pause": "⏸ Pause",
|
||||
"panel_print_btn_resume": "▶ Resume",
|
||||
@@ -326,4 +325,4 @@
|
||||
"update_error": "Error",
|
||||
"update_none": "Already up to date",
|
||||
"update_restarting": "Restarting..."
|
||||
}
|
||||
}
|
||||
@@ -189,8 +189,7 @@
|
||||
"panel_extras_camera": "Cámara",
|
||||
"panel_extras_fan": "Ventilador",
|
||||
"panel_extras_light": "Luz",
|
||||
"panel_motion_xy": "Ejes XY",
|
||||
"panel_motion_z": "Eje Z",
|
||||
"panel_motion_xy": "Control de Ejes",
|
||||
"panel_print_btn_cancel": "✕ Cancelar",
|
||||
"panel_print_btn_pause": "⏸ Pausa",
|
||||
"panel_print_btn_resume": "▶ Reanudar",
|
||||
@@ -326,4 +325,4 @@
|
||||
"update_error": "Error",
|
||||
"update_none": "Ya actualizado",
|
||||
"update_restarting": "Reiniciando..."
|
||||
}
|
||||
}
|
||||
@@ -189,8 +189,7 @@
|
||||
"panel_extras_camera": "Caméra",
|
||||
"panel_extras_fan": "Ventilateur",
|
||||
"panel_extras_light": "Lumière",
|
||||
"panel_motion_xy": "Axes XY",
|
||||
"panel_motion_z": "Axe Z",
|
||||
"panel_motion_xy": "Contrôle des Axes",
|
||||
"panel_print_btn_cancel": "✕ Annuler",
|
||||
"panel_print_btn_pause": "⏸ Pause",
|
||||
"panel_print_btn_resume": "▶ Reprendre",
|
||||
@@ -326,4 +325,4 @@
|
||||
"update_error": "Erreur",
|
||||
"update_none": "Déjà à jour",
|
||||
"update_restarting": "Redémarrage…"
|
||||
}
|
||||
}
|
||||
@@ -189,8 +189,7 @@
|
||||
"panel_extras_camera": "Camera",
|
||||
"panel_extras_fan": "Ventola",
|
||||
"panel_extras_light": "Luce",
|
||||
"panel_motion_xy": "Assi XY",
|
||||
"panel_motion_z": "Asse Z",
|
||||
"panel_motion_xy": "Controllo Assi",
|
||||
"panel_print_btn_cancel": "✕ Annulla",
|
||||
"panel_print_btn_pause": "⏸ Pausa",
|
||||
"panel_print_btn_resume": "▶ Riprendi",
|
||||
@@ -326,4 +325,4 @@
|
||||
"update_error": "Errore",
|
||||
"update_none": "Già aggiornato",
|
||||
"update_restarting": "Riavvio in corso..."
|
||||
}
|
||||
}
|
||||
@@ -189,8 +189,7 @@
|
||||
"panel_extras_camera": "相机",
|
||||
"panel_extras_fan": "风扇",
|
||||
"panel_extras_light": "灯光",
|
||||
"panel_motion_xy": "XY 轴",
|
||||
"panel_motion_z": "Z 轴",
|
||||
"panel_motion_xy": "轴控制",
|
||||
"panel_print_btn_cancel": "✕ 取消",
|
||||
"panel_print_btn_pause": "⏸ 暂停",
|
||||
"panel_print_btn_resume": "▶ 继续",
|
||||
@@ -326,4 +325,4 @@
|
||||
"update_error": "错误",
|
||||
"update_none": "已是最新版本",
|
||||
"update_restarting": "重启中..."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user