forked from viewit/KX-Bridge-Release
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f5aa8d126 | |||
| c313e014ad | |||
| 6e9ba0672f | |||
| 44383fabec | |||
| 48bec55611 | |||
| ab44e234be | |||
| 74fc2ddab0 | |||
| 771599be0c | |||
| 0e1d46ee7f | |||
| 15e28244af | |||
| 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 | |||
| 3531cad0ef | |||
| f192a9943d | |||
| eb7fd44f68 | |||
| e5b2a19192 | |||
| 2f59a2b02b | |||
| bc9bfb58ea | |||
| ac309d5d3d | |||
| 38d98666c4 | |||
| e7c978a067 | |||
| d7c2dccef5 | |||
| e59550b5a0 | |||
| 5871e851da | |||
| e70e9c82d7 | |||
| be110fd766 | |||
| 77fce988d7 | |||
| fe1815c76f | |||
| 29a4262a2a | |||
| e753bcdb03 | |||
| 73ecb1e618 | |||
| 08d9a7ab0f | |||
| 93f72f0be9 | |||
| 812936d90d | |||
| 326453e2fd | |||
| ffd8ed09d5 | |||
| dc7e92688b | |||
| 3e1ba9df4b | |||
| 41f4700b24 | |||
| 216b2de2c0 | |||
| 394b0e69ab | |||
| 877cddb1ba | |||
| c9043e9630 | |||
| 6165a7f62a | |||
| fa8e0c1491 | |||
| 282c02ae0a | |||
| 72f77d92af |
31
.claude/agents/changelog.md
Normal file
31
.claude/agents/changelog.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: KX-Bridge Changelog
|
||||||
|
description: Generiert einen CHANGELOG.md Eintrag aus Git-Commits seit dem letzten Tag.
|
||||||
|
tools:
|
||||||
|
- run_command
|
||||||
|
- read_file
|
||||||
|
- write_file
|
||||||
|
---
|
||||||
|
|
||||||
|
Du generierst CHANGELOG.md Einträge für KX-Bridge.
|
||||||
|
|
||||||
|
Vorgehen:
|
||||||
|
1. Führe aus: `git log $(git describe --tags --abbrev=0)..HEAD --oneline`
|
||||||
|
2. Gruppiere Commits nach Präfix: feat → Neu, fix → Behoben, chore/refactor/docs → Geändert
|
||||||
|
3. Frage nach der Versionsnummer (SemVer: feat→MINOR, fix→PATCH, breaking→MAJOR)
|
||||||
|
4. Schreibe den Abschnitt im Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## [VERSION] - DATUM
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- ...
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- ...
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Füge den Abschnitt am Anfang der bestehenden CHANGELOG.md ein, ohne vorhandene Einträge zu ändern.
|
||||||
32
.claude/agents/docker-check.md
Normal file
32
.claude/agents/docker-check.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: KX-Bridge Docker Check
|
||||||
|
description: Prüft Dockerfile, docker-compose und das gebaute Image auf häufige Probleme.
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- run_command
|
||||||
|
- search_files
|
||||||
|
---
|
||||||
|
|
||||||
|
Du prüfst die Docker-Konfiguration von KX-Bridge.
|
||||||
|
|
||||||
|
**Dockerfile:**
|
||||||
|
- Base-Image aktuell? (`python:3.11-slim` oder neuer)
|
||||||
|
- `.dockerignore` vorhanden und vollständig?
|
||||||
|
- Keine Secrets oder Zertifikate im Image (`anycubic_slicer.crt/.key` darf NICHT eingebettet sein)
|
||||||
|
- Healthcheck vorhanden?
|
||||||
|
- Kein `COPY . .` ohne `.dockerignore`
|
||||||
|
|
||||||
|
**docker-compose.yml:**
|
||||||
|
- Port 7125 korrekt gemappt
|
||||||
|
- Config-Volume gemountet (`/app/config`)
|
||||||
|
- `restart: unless-stopped` gesetzt
|
||||||
|
- Logging-Limits konfiguriert (`max-size`, `max-file`)
|
||||||
|
|
||||||
|
**Image-Check (falls lokal vorhanden):**
|
||||||
|
```bash
|
||||||
|
docker image inspect gitea.it-drui.de/viewit/kx-bridge:nightly
|
||||||
|
```
|
||||||
|
- Image-Größe sinnvoll (< 500MB)?
|
||||||
|
- Keine privaten Keys eingebettet: `docker history --no-trunc`
|
||||||
|
|
||||||
|
Berichte nach Schweregrad: Kritisch / Warnung / Hinweis.
|
||||||
23
.claude/agents/moonraker-debug.md
Normal file
23
.claude/agents/moonraker-debug.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: KX-Bridge Moonraker Debug
|
||||||
|
description: Analysiert Moonraker/Klipper Logs und KX-Bridge Ausgaben auf Fehlerursachen.
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- search_files
|
||||||
|
---
|
||||||
|
|
||||||
|
Du analysierst Logs für KX-Bridge im Kontext Moonraker/Klipper/AFC.
|
||||||
|
|
||||||
|
**Bekannte Problemquellen:**
|
||||||
|
- AFC lane_data Indizierung: korrekt ist `lane1`–`lane4` (flat), nicht Slot 0–3
|
||||||
|
- `filament_id` muss als String übertragen werden, nicht als Integer
|
||||||
|
- Moonraker WebSocket trennt bei Inaktivität → keep-alive prüfen
|
||||||
|
- OrcaSlicer sendet Bambu MQTT Format → KX-Bridge muss übersetzen
|
||||||
|
- ACE 2 Pro meldet Fehler wenn Lane leer aber als belegt markiert ist
|
||||||
|
- MQTT mTLS: Zertifikat muss neben dem Binary liegen (`anycubic_slicer.crt/.key`)
|
||||||
|
|
||||||
|
**Bei einem Log:**
|
||||||
|
1. Identifiziere den **ersten** Fehler (nicht den letzten Symptom)
|
||||||
|
2. Zeige den relevanten Log-Kontext (±10 Zeilen um den Fehler)
|
||||||
|
3. Nenne die wahrscheinliche Ursache
|
||||||
|
4. Schlage einen konkreten Fix vor (Datei + Funktion wenn möglich)
|
||||||
25
.claude/agents/nightly-prep.md
Normal file
25
.claude/agents/nightly-prep.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: KX-Bridge Nightly Prep
|
||||||
|
description: Bereitet den PR von nightly nach main vor. Prüft ob alle Voraussetzungen für ein Stable Release erfüllt sind.
|
||||||
|
tools:
|
||||||
|
- run_command
|
||||||
|
- read_file
|
||||||
|
---
|
||||||
|
|
||||||
|
Du bereitest einen nightly → main Merge für KX-Bridge vor.
|
||||||
|
|
||||||
|
Führe folgende Checks aus und berichte:
|
||||||
|
|
||||||
|
1. `git log main..nightly --oneline` → alle Commits die noch nicht in main sind
|
||||||
|
2. `git diff main..nightly -- CHANGELOG.md` → ist CHANGELOG.md für alle Änderungen aktualisiert?
|
||||||
|
3. Prüfe ob `tests/` alle geänderten Module abdeckt
|
||||||
|
4. Prüfe ob Dockerfile ein aktuelles Base-Image verwendet
|
||||||
|
5. Schlage eine SemVer-Versionsnummer vor:
|
||||||
|
- `feat:` Commits → MINOR erhöhen
|
||||||
|
- `fix:` Commits → PATCH erhöhen
|
||||||
|
- Breaking Change im Commit-Body → MAJOR erhöhen
|
||||||
|
|
||||||
|
Abschlussbericht:
|
||||||
|
- ✅ Bereit für Release
|
||||||
|
- ⚠️ Offen: [Liste]
|
||||||
|
- ❌ Blockiert durch: [Grund]
|
||||||
24
.claude/agents/reviewer.md
Normal file
24
.claude/agents/reviewer.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: KX-Bridge Reviewer
|
||||||
|
description: Reviewt geänderte Dateien vor einem PR auf nightly. Prüft Logik, Fehlerbehandlung, Moonraker-Kompatibilität und Stil.
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- list_directory
|
||||||
|
- search_files
|
||||||
|
---
|
||||||
|
|
||||||
|
Du bist Code-Reviewer für KX-Bridge — eine Python-Bridge zwischen OrcaSlicer und Moonraker/Klipper für den Anycubic Kobra X.
|
||||||
|
|
||||||
|
Beim Review prüfst du:
|
||||||
|
- Korrekte Fehlerbehandlung bei Moonraker HTTP/MQTT Calls (keine unbehandelten Exceptions)
|
||||||
|
- Keine hardcodierten IPs oder Ports (müssen aus config.ini kommen)
|
||||||
|
- Thread-Sicherheit bei parallelen Moonraker-Abfragen (asyncio korrekt verwendet)
|
||||||
|
- AFC lane_data Struktur: flache Indizierung lane1–lane4, kein Slot-Mapping 0–3
|
||||||
|
- Kein `print()` statt `logging` (außer in CLI-Hilfsfunktionen)
|
||||||
|
- Typ-Annotationen vorhanden, Python 3.8+ kompatibel (kein `X | Y` Syntax)
|
||||||
|
- Tests für neue öffentliche Funktionen vorhanden
|
||||||
|
|
||||||
|
Ausgabeformat:
|
||||||
|
1. **Kritische Fehler** — blockieren den Merge
|
||||||
|
2. **Warnungen** — sollten vor Merge behoben werden
|
||||||
|
3. **Hinweise** — optional, für zukünftige Verbesserungen
|
||||||
26
.claude/agents/test-writer.md
Normal file
26
.claude/agents/test-writer.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: KX-Bridge Test Writer
|
||||||
|
description: Leitet pytest-Tests aus geänderten oder neuen Python-Dateien ab.
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- write_file
|
||||||
|
- list_directory
|
||||||
|
- search_files
|
||||||
|
---
|
||||||
|
|
||||||
|
Du schreibst pytest-Tests für KX-Bridge.
|
||||||
|
|
||||||
|
Kontext:
|
||||||
|
- Moonraker API läuft auf Port 7125 (HTTP + WebSocket)
|
||||||
|
- AFC lane_data: flache Indizierung lane1–lane4
|
||||||
|
- Externe HTTP-Calls zu Moonraker werden mit `unittest.mock` gemockt
|
||||||
|
- Python 3.8+ Kompatibilität (kein `X | Y` Union-Syntax)
|
||||||
|
|
||||||
|
Für jede zu testende Funktion schreibst du:
|
||||||
|
1. Happy Path (Normalfall mit validen Eingaben)
|
||||||
|
2. Fehlerfall (Moonraker nicht erreichbar, Timeout, falsche Antwort)
|
||||||
|
3. Grenzwerte (leere lane_data, ungültige filament_id, None-Werte)
|
||||||
|
|
||||||
|
Dateiname: `tests/test_<modulname>.py`
|
||||||
|
Verwende pytest-Fixtures für Moonraker-Mock-Responses.
|
||||||
|
Keine echten Netzwerkaufrufe in Tests.
|
||||||
12
.claude/settings.json
Normal file
12
.claude/settings.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"project": "KX-Bridge",
|
||||||
|
"language": "de",
|
||||||
|
"defaultAgent": "reviewer",
|
||||||
|
"context": {
|
||||||
|
"repoBase": "gitea.it-drui.de/viewit/KX-Bridge-Release",
|
||||||
|
"defaultBranch": "nightly",
|
||||||
|
"stableBranch": "main",
|
||||||
|
"registry": "gitea.it-drui.de/viewit/kx-bridge",
|
||||||
|
"moonrakerPort": 7125
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# EditorConfig helps maintain consistent coding styles across all files
|
|
||||||
# https://editorconfig.org
|
|
||||||
|
|
||||||
root = true
|
|
||||||
|
|
||||||
# Unix-style newlines, UTF-8 encoding for all files
|
|
||||||
[*]
|
|
||||||
end_of_line = lf
|
|
||||||
charset = utf-8
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
|
|
||||||
# Python: 4 spaces (PEP 8)
|
|
||||||
[*.py]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
# JavaScript/JSON/YAML: 2 spaces (common web standard)
|
|
||||||
[*.{js,json,yml,yaml}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
# HTML/CSS: 2 spaces
|
|
||||||
[*.{html,css}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
# Markdown: preserve formatting
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
29
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
29
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Report a bug in KX-Bridge
|
||||||
|
labels: bug
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
<!-- What is happening? -->
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
## Actual Behavior
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
- KX-Bridge Version:
|
||||||
|
- OrcaSlicer Version:
|
||||||
|
- Moonraker/Klipper Version:
|
||||||
|
- Operating System:
|
||||||
|
- Installation: Docker / Binary
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
```
|
||||||
|
<!-- docker logs kx-bridge --tail 50 -->
|
||||||
|
```
|
||||||
14
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
14
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Suggest a new feature or improvement
|
||||||
|
labels: enhancement
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
<!-- What should be added or improved? -->
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
<!-- Why is this useful? What problem does it solve? -->
|
||||||
|
|
||||||
|
## Proposed Implementation
|
||||||
|
<!-- Optional: How could this be implemented technically? -->
|
||||||
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)
|
||||||
21
.gitea/pull_request_template.md
Normal file
21
.gitea/pull_request_template.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
## Description
|
||||||
|
<!-- What does this PR change? -->
|
||||||
|
|
||||||
|
## Related Issue
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Type
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] Feature
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Refactoring
|
||||||
|
|
||||||
|
## Tested with
|
||||||
|
- OrcaSlicer Version:
|
||||||
|
- Printer:
|
||||||
|
- Moonraker/Klipper Version:
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Tests added/updated
|
||||||
|
- [ ] CHANGELOG.md updated
|
||||||
|
- [ ] No debug code included
|
||||||
176
.gitea/workflows/nightly.yml
Normal file
176
.gitea/workflows/nightly.yml
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
name: Nightly Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- nightly
|
||||||
|
paths:
|
||||||
|
- '**.py'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- 'requirements.txt'
|
||||||
|
- 'web/**'
|
||||||
|
- 'data/**'
|
||||||
|
- '.gitea/workflows/nightly.yml'
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: server-runner
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
if [ -d .git ]; then
|
||||||
|
git fetch --tags origin nightly
|
||||||
|
git reset --hard origin/nightly
|
||||||
|
git clean -fd
|
||||||
|
else
|
||||||
|
git clone --branch nightly https://gitea.it-drui.de/viewit/KX-Bridge-Release.git .
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: |
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [ "$ARCH" = "x86_64" ]; then
|
||||||
|
DARCH="x86_64"
|
||||||
|
BARCH="amd64"
|
||||||
|
else
|
||||||
|
DARCH="aarch64"
|
||||||
|
BARCH="arm64"
|
||||||
|
fi
|
||||||
|
wget -qO- "https://download.docker.com/linux/static/stable/${DARCH}/docker-27.5.1.tgz" \
|
||||||
|
| tar xz --strip-components=1 -C /usr/local/bin docker/docker
|
||||||
|
chmod +x /usr/local/bin/docker
|
||||||
|
mkdir -p /usr/local/lib/docker/cli-plugins
|
||||||
|
wget -qO /usr/local/lib/docker/cli-plugins/docker-buildx \
|
||||||
|
"https://github.com/docker/buildx/releases/download/v0.23.0/buildx-v0.23.0.linux-${BARCH}"
|
||||||
|
chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx
|
||||||
|
fi
|
||||||
|
docker version --format '{{.Client.Version}}'
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
run: |
|
||||||
|
docker run --rm --privileged tonistiigi/binfmt:latest --install all
|
||||||
|
|
||||||
|
- name: Set up buildx
|
||||||
|
run: |
|
||||||
|
docker buildx inspect kxbuilder 2>/dev/null || \
|
||||||
|
docker buildx create --name kxbuilder --use
|
||||||
|
docker buildx use kxbuilder
|
||||||
|
|
||||||
|
- name: Login to Gitea registry
|
||||||
|
run: |
|
||||||
|
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: |
|
||||||
|
. /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,linux/arm/v7 \
|
||||||
|
--push \
|
||||||
|
--provenance=false \
|
||||||
|
--no-cache \
|
||||||
|
-t "gitea.it-drui.de/viewit/kx-bridge:nightly" \
|
||||||
|
-t "gitea.it-drui.de/viewit/kx-bridge:nightly-${VERSION}" \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Create Gitea Nightly Release
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
. /tmp/nightly_version.env
|
||||||
|
TAG="nightly-${VERSION}"
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# 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 '[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"
|
||||||
|
|
||||||
|
# Tag setzen
|
||||||
|
git tag "$TAG"
|
||||||
|
git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git "$TAG"
|
||||||
|
|
||||||
|
# curl + jq 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
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
JQ_ARCH="amd64"; [ "$ARCH" = "aarch64" ] && JQ_ARCH="arm64"
|
||||||
|
wget -qO /usr/local/bin/jq \
|
||||||
|
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-${JQ_ARCH}"
|
||||||
|
chmod +x /usr/local/bin/jq
|
||||||
|
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 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" \
|
||||||
|
--data-binary @/tmp/release_body.json \
|
||||||
|
"https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases"
|
||||||
|
rm -f "$BODY_FILE" /tmp/release_body.json
|
||||||
34
.gitea/workflows/pr-check.yml
Normal file
34
.gitea/workflows/pr-check.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: PR Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- nightly
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-test:
|
||||||
|
runs-on: server-runner
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
if [ -d .git ]; then
|
||||||
|
git fetch origin
|
||||||
|
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 .
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Dependencies installieren
|
||||||
|
run: pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: |
|
||||||
|
pip3 install flake8
|
||||||
|
flake8 *.py --max-line-length=120 --extend-ignore=E501
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: |
|
||||||
|
pip3 install pytest
|
||||||
|
pytest tests/ -v
|
||||||
|
if: ${{ hashFiles('tests/') != '' }}
|
||||||
74
.gitea/workflows/release.yml
Normal file
74
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
name: Stable Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: server-runner
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
if [ -d .git ]; then
|
||||||
|
git fetch --tags origin
|
||||||
|
git checkout "$TAG"
|
||||||
|
git clean -fd
|
||||||
|
else
|
||||||
|
git clone --depth=1 --branch "$TAG" https://gitea.it-drui.de/viewit/KX-Bridge-Release.git .
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: |
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [ "$ARCH" = "x86_64" ]; then
|
||||||
|
DARCH="x86_64"
|
||||||
|
BARCH="amd64"
|
||||||
|
else
|
||||||
|
DARCH="aarch64"
|
||||||
|
BARCH="arm64"
|
||||||
|
fi
|
||||||
|
wget -qO- "https://download.docker.com/linux/static/stable/${DARCH}/docker-27.5.1.tgz" \
|
||||||
|
| tar xz --strip-components=1 -C /usr/local/bin docker/docker
|
||||||
|
chmod +x /usr/local/bin/docker
|
||||||
|
mkdir -p /usr/local/lib/docker/cli-plugins
|
||||||
|
wget -qO /usr/local/lib/docker/cli-plugins/docker-buildx \
|
||||||
|
"https://github.com/docker/buildx/releases/download/v0.23.0/buildx-v0.23.0.linux-${BARCH}"
|
||||||
|
chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx
|
||||||
|
fi
|
||||||
|
docker version --format '{{.Client.Version}}'
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
run: |
|
||||||
|
docker run --rm --privileged tonistiigi/binfmt:latest --install all
|
||||||
|
|
||||||
|
- name: Set up buildx
|
||||||
|
run: |
|
||||||
|
docker buildx inspect kxbuilder 2>/dev/null || \
|
||||||
|
docker buildx create --name kxbuilder --use
|
||||||
|
docker buildx use kxbuilder
|
||||||
|
|
||||||
|
- name: Login to Gitea registry
|
||||||
|
run: |
|
||||||
|
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/v}"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||||
|
--push \
|
||||||
|
--provenance=false \
|
||||||
|
--no-cache \
|
||||||
|
-t "gitea.it-drui.de/viewit/kx-bridge:latest" \
|
||||||
|
-t "gitea.it-drui.de/viewit/kx-bridge:${VERSION}" \
|
||||||
|
.
|
||||||
|
|
||||||
|
# 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.
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -4,11 +4,7 @@ __pycache__/
|
|||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
*.spec
|
*.spec
|
||||||
releases/*/kx-bridge
|
releases/
|
||||||
releases/*/extract_credentials
|
|
||||||
releases/*/extract_credentials.exe
|
|
||||||
|
|
||||||
node_modules/*
|
|
||||||
|
|
||||||
!kx-bridge.spec
|
!kx-bridge.spec
|
||||||
|
|
||||||
@@ -19,3 +15,8 @@ config/*.ini
|
|||||||
data/
|
data/
|
||||||
|
|
||||||
!data/orca_filaments.json
|
!data/orca_filaments.json
|
||||||
|
.runner-token
|
||||||
|
|
||||||
|
# Dev-only Dateien — nicht ins öffentliche Repo
|
||||||
|
CLAUDE.md
|
||||||
|
release.sh
|
||||||
|
|||||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Filament profiles not isolated between printers in a multi-printer bridge**
|
||||||
|
(issue #74). The slot→profile mapping and `visible_vendors` were stored in a
|
||||||
|
single global `[filament_profiles]` section, so configuring one printer
|
||||||
|
overwrote the other and after a restart both loaded the same mapping. Each
|
||||||
|
printer now persists to its own `[filament_profiles_<id>]` section, with a
|
||||||
|
read-fallback to the legacy global section (single-printer setups unchanged).
|
||||||
|
- **Printer dropdown showed the other printer's filament profiles** (issue #74).
|
||||||
|
The header dropdown and the printers-management "switch" link navigated within
|
||||||
|
the same port (`/printerN`), so viewing another printer pulled its profile
|
||||||
|
names cross-instance from the local origin. The links now point at each
|
||||||
|
printer's own `bridge_url`, so every printer is viewed same-origin on its own
|
||||||
|
port.
|
||||||
|
|
||||||
## [0.9.26] – 2026-06-21
|
## [0.9.26] – 2026-06-21
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
102
CONTRIBUTING.md
Normal file
102
CONTRIBUTING.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Contributing to KX-Bridge
|
||||||
|
|
||||||
|
Thanks for taking the time to contribute! Here's everything you need to know.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to report a bug or request a feature
|
||||||
|
|
||||||
|
Use the issue tracker:
|
||||||
|
|
||||||
|
- **Bug:** [New Bug Report](https://gitea.it-drui.de/viewit/KX-Bridge-Release/issues/new?template=bug_report.md)
|
||||||
|
- **Feature:** [New Feature Request](https://gitea.it-drui.de/viewit/KX-Bridge-Release/issues/new?template=feature_request.md)
|
||||||
|
|
||||||
|
Please fill in the template — especially the **KX-Bridge version** and **logs**.
|
||||||
|
Issues without version info are hard to debug.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to submit a Pull Request
|
||||||
|
|
||||||
|
### 1. Fork the repository
|
||||||
|
|
||||||
|
Click **Fork** at the top of this page.
|
||||||
|
You now have your own copy at `gitea.it-drui.de/your-username/KX-Bridge-Release`.
|
||||||
|
|
||||||
|
### 2. Clone your fork
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.it-drui.de/your-username/KX-Bridge-Release.git
|
||||||
|
cd KX-Bridge-Release
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create a branch
|
||||||
|
|
||||||
|
Always branch off `nightly`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout nightly
|
||||||
|
git checkout -b feature/my-feature # or fix/my-fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Make your changes
|
||||||
|
|
||||||
|
- Test your changes locally with Docker:
|
||||||
|
```bash
|
||||||
|
docker build -t kx-bridge:dev .
|
||||||
|
docker run -p 7125:7125 -v ./config:/app/config kx-bridge:dev
|
||||||
|
```
|
||||||
|
- No debug `print()` statements — use `logging`
|
||||||
|
- Keep commits focused; one thing per commit
|
||||||
|
|
||||||
|
### 5. Push and open a PR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin feature/my-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitea will show a banner — click **"Create Pull Request"**.
|
||||||
|
The PR template will be pre-filled. Set the target branch to **`nightly`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch model
|
||||||
|
|
||||||
|
```
|
||||||
|
master ← stable releases only (merged by maintainer)
|
||||||
|
nightly ← integration branch — PRs go here
|
||||||
|
feature/* ← your feature branch (in your fork)
|
||||||
|
fix/* ← your bugfix branch (in your fork)
|
||||||
|
```
|
||||||
|
|
||||||
|
Your PR always targets `nightly`. The maintainer periodically merges `nightly → master` for a new stable release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit style
|
||||||
|
|
||||||
|
Use conventional commit prefixes:
|
||||||
|
|
||||||
|
| Prefix | When |
|
||||||
|
|---|---|
|
||||||
|
| `feat:` | new feature |
|
||||||
|
| `fix:` | bug fix |
|
||||||
|
| `docs:` | documentation only |
|
||||||
|
| `chore:` | maintenance, dependencies |
|
||||||
|
| `refactor:` | code change without new feature or fix |
|
||||||
|
|
||||||
|
Example: `fix: prevent crash when printer is offline during startup`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
- **Code and comments:** English
|
||||||
|
- **Issue comments:** match the language of the issue (if someone writes in German, reply in German)
|
||||||
|
- **Commit messages:** English
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Open a [Discussion](https://gitea.it-drui.de/viewit/KX-Bridge-Release/issues) or leave a comment on the relevant issue.
|
||||||
@@ -2,10 +2,11 @@ FROM python:3.11-slim-bookworm
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg gcc python3-dev && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||||
|
apt-get purge -y gcc python3-dev && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY kobrax_moonraker_bridge.py .
|
COPY kobrax_moonraker_bridge.py .
|
||||||
COPY web/ ./web/
|
COPY web/ ./web/
|
||||||
|
|||||||
11
NIGHTLY_CHANGELOG.md
Normal file
11
NIGHTLY_CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
## 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 tile) and in the Filaments settings tab (dedicated assignment card)
|
||||||
|
- Fix: filament profiles now isolated per printer in multi-printer setups — configuring one printer no longer overwrites the other (PR #75 by @walterioo)
|
||||||
|
- Fix: printer dropdown and switch link now navigate to each printer's own bridge URL (same-origin, no cross-instance profile bleed)
|
||||||
|
- Fix: Spoolman sync rate label corrected — 0 means sync at end of print, not disabled (Issue #76)
|
||||||
|
- Slot color editor: Pickr color picker (HSV wheel + hex input), recent color swatches (up to 16, saved in browser), and "Copy color from slot" dropdown for identical backup spool setup (Issue #73)
|
||||||
|
- UI: unified dropdown and input field styling across all settings panels
|
||||||
27
README.de.md
27
README.de.md
@@ -21,7 +21,7 @@ Feedback willkommen.</sub>
|
|||||||
|
|
||||||
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||||
|
|
||||||
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||||
|
|
||||||
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
||||||
|
|
||||||
@@ -29,6 +29,11 @@ Feedback willkommen.</sub>
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Laufende Wartungsarbeiten** — Wir strukturieren das Repository um (Branch-Modell, CI-Workflows, Beitragsprozess). Es kann zu Änderungen bei Branch-Namen, PR-Templates und der Art der Veröffentlichungen kommen. Wir entschuldigen uns für etwaige Unannehmlichkeiten. Handhabung, Workflow und langfristige Wartbarkeit werden dadurch deutlich verbessert.
|
||||||
|
>
|
||||||
|
> 👉 Möchtest du beitragen? Bitte zuerst [CONTRIBUTING.md](CONTRIBUTING.md) lesen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Was kann KX-Bridge?
|
## ✨ Was kann KX-Bridge?
|
||||||
@@ -45,7 +50,7 @@ Feedback willkommen.</sub>
|
|||||||
| 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown |
|
| 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown |
|
||||||
| ➕ | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert |
|
| ➕ | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert |
|
||||||
| 🔁 | **Robuster MQTT-Reconnect** — Bridge überlebt nächtlichen Drucker-Reboot ohne manuellen Neustart |
|
| 🔁 | **Robuster MQTT-Reconnect** — Bridge überlebt nächtlichen Drucker-Reboot ohne manuellen Neustart |
|
||||||
| 🌐 | **Mehrsprachiges UI** — DE / EN / ES / 中文, Browser-Sprache automatisch erkannt |
|
| 🌐 | **Mehrsprachiges UI** — DE / EN / ES / FR / IT / 中文, Browser-Sprache automatisch erkannt |
|
||||||
| 🔄 | **Self-Update** — neue Versionen direkt im Browser installieren |
|
| 🔄 | **Self-Update** — neue Versionen direkt im Browser installieren |
|
||||||
| 🧠 | **OrcaSlicer** — volles Moonraker-Protokoll (HTTP + WebSocket); für korrekten Vendor-Match pro Slot den [OrcaSlicer-KX-Build](#-empfohlener-slicer) nutzen |
|
| 🧠 | **OrcaSlicer** — volles Moonraker-Protokoll (HTTP + WebSocket); für korrekten Vendor-Match pro Slot den [OrcaSlicer-KX-Build](#-empfohlener-slicer) nutzen |
|
||||||
|
|
||||||
@@ -184,6 +189,24 @@ docker compose up -d --build # lokal selber bauen (statt zu pullen)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🌙 Nightly-Builds
|
||||||
|
|
||||||
|
Nightly-Builds enthalten die neuesten unveröffentlichten Features und werden automatisch bei jedem Entwicklungs-Push gebaut.
|
||||||
|
Sie können instabil sein — für Tests oder frühen Zugriff auf neue Funktionen geeignet.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.nightly.yml pull
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.nightly.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Zurück zum stabilen Release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🩹 Troubleshooting
|
## 🩹 Troubleshooting
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
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)
|
||||||
61
README.es.md
61
README.es.md
@@ -20,7 +20,7 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
|
|||||||
|
|
||||||
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||||
|
|
||||||
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||||
|
|
||||||
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
||||||
|
|
||||||
@@ -28,6 +28,11 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Trabajos de mantenimiento en curso** — Estamos reestructurando el repositorio (modelo de ramas, flujos CI, proceso de contribución). Es posible que notes cambios en los nombres de ramas, plantillas de PR y la forma en que se publican las versiones. Pedimos disculpas por las molestias. El manejo, el flujo de trabajo y la mantenibilidad a largo plazo mejorarán considerablemente.
|
||||||
|
>
|
||||||
|
> 👉 ¿Quieres contribuir? Por favor lee [CONTRIBUTING.md](CONTRIBUTING.md) primero.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Características
|
## ✨ Características
|
||||||
@@ -44,7 +49,7 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
|
|||||||
| 🧩 | **Multi-impresora** — múltiples impresoras en **una** instancia del puente, cambia mediante un menú desplegable |
|
| 🧩 | **Multi-impresora** — múltiples impresoras en **una** instancia del puente, cambia mediante un menú desplegable |
|
||||||
| ➕ | **Añade una impresora con un clic** — solo introduce la IP, las credenciales se importan automáticamente |
|
| ➕ | **Añade una impresora con un clic** — solo introduce la IP, las credenciales se importan automáticamente |
|
||||||
| 🔁 | **Reconexión MQTT robusta** — el puente sobrevive a reinicios nocturnos de la impresora sin reinicio manual |
|
| 🔁 | **Reconexión MQTT robusta** — el puente sobrevive a reinicios nocturnos de la impresora sin reinicio manual |
|
||||||
| 🌐 | **Interfaz multilingüe** — DE / EN / ES / 中文, detecta automáticamente el idioma del navegador |
|
| 🌐 | **Interfaz multilingüe** — DE / EN / ES / FR / IT / 中文, detecta automáticamente el idioma del navegador |
|
||||||
| 🔄 | **Actualización automática** — instala nuevas versiones directamente desde el navegador |
|
| 🔄 | **Actualización automática** — instala nuevas versiones directamente desde el navegador |
|
||||||
| 🧠 | **OrcaSlicer** — protocolo Moonraker completo (HTTP + WebSocket); usa el [build OrcaSlicer-KX](#-slicer-recomendado) para emparejamiento correcto de vendor por ranura |
|
| 🧠 | **OrcaSlicer** — protocolo Moonraker completo (HTTP + WebSocket); usa el [build OrcaSlicer-KX](#-slicer-recomendado) para emparejamiento correcto de vendor por ranura |
|
||||||
|
|
||||||
@@ -66,43 +71,17 @@ docker compose up -d
|
|||||||
|
|
||||||
**Binario Linux (sin Docker):**
|
**Binario Linux (sin Docker):**
|
||||||
```bash
|
```bash
|
||||||
chmod +x kx-bridge-linux-amd64 && ./kx-bridge-linux-amd64
|
chmod +x kx-bridge && ./kx-bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
**EXE Windows (sin Docker):**
|
**EXE Windows (sin Docker):**
|
||||||
```
|
```
|
||||||
kx-bridge.exe
|
kx-bridge.exe
|
||||||
```
|
```
|
||||||
|
> `config\` y `data\` se crean junto al EXE — instalación portátil.
|
||||||
|
|
||||||
> ⚠️ **Certificados TLS necesarios para el binario standalone**
|
> Con los binarios de Linux y Windows, `config/` y `data/` (configuración, SQLite, almacén de GCode)
|
||||||
>
|
> viven junto al programa. Copia toda la carpeta para mover la instalación.
|
||||||
> El bridge habla con el MQTT de la impresora vía mTLS y necesita dos
|
|
||||||
> ficheros de certificado **junto al binario**:
|
|
||||||
>
|
|
||||||
> - `anycubic_slicer.crt`
|
|
||||||
> - `anycubic_slicer.key`
|
|
||||||
>
|
|
||||||
> Ambos vienen en **`anycubic-certs.zip`** en la misma página de release.
|
|
||||||
> Descárgalo y extrae los dos ficheros en el mismo directorio que
|
|
||||||
> `kx-bridge-linux-amd64` o `kx-bridge.exe`. Sin ellos verás
|
|
||||||
> `Verbindung fehlgeschlagen: TLS-Zertifikate fehlen …` (0.9.19.1+) o
|
|
||||||
> `[Errno 2] No such file or directory` (versiones anteriores).
|
|
||||||
>
|
|
||||||
> Estructura correcta:
|
|
||||||
> ```
|
|
||||||
> ~/kx-bridge/
|
|
||||||
> ├── kx-bridge-linux-amd64 (o kx-bridge.exe)
|
|
||||||
> ├── anycubic_slicer.crt ← de anycubic-certs.zip
|
|
||||||
> ├── anycubic_slicer.key ← de anycubic-certs.zip
|
|
||||||
> └── config/ (se crea en el primer arranque)
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> Los usuarios de Docker no necesitan hacer esto — los certificados
|
|
||||||
> están incluidos en la imagen.
|
|
||||||
|
|
||||||
> Con los binarios de Linux y Windows, `config/` y `data/` (configuración,
|
|
||||||
> SQLite, almacén de GCode) viven junto al programa. Copia toda la carpeta
|
|
||||||
> para mover la instalación.
|
|
||||||
|
|
||||||
**Python directamente:**
|
**Python directamente:**
|
||||||
```bash
|
```bash
|
||||||
@@ -208,6 +187,24 @@ docker compose up -d --build # recompilar localmente (en lugar de desc
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🌙 Builds nocturnos (Nightly)
|
||||||
|
|
||||||
|
Los builds nocturnos contienen las últimas funciones no publicadas y se generan automáticamente en cada push de desarrollo.
|
||||||
|
Pueden ser inestables — úsalos para pruebas o acceso anticipado a nuevas funciones.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.nightly.yml pull
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.nightly.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Volver a la versión estable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🩹 Solución de problemas
|
## 🩹 Solución de problemas
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -20,7 +20,7 @@ officially tested or supported. Feedback welcome.</sub>
|
|||||||
|
|
||||||
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||||
|
|
||||||
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||||
|
|
||||||
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
||||||
|
|
||||||
@@ -28,6 +28,11 @@ officially tested or supported. Feedback welcome.</sub>
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Ongoing maintenance work** — We are restructuring the repository (branch model, CI workflows, contribution process). You may notice changes to branch names, PR templates, and how releases are published. We apologise for any inconvenience. Handling, workflow, and long-term maintainability will be significantly improved as a result.
|
||||||
|
>
|
||||||
|
> 👉 Want to contribute? Please read [CONTRIBUTING.md](CONTRIBUTING.md) first.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
@@ -44,7 +49,7 @@ officially tested or supported. Feedback welcome.</sub>
|
|||||||
| 🧩 | **Multi-printer** — multiple printers in **one** bridge instance, switch via dropdown |
|
| 🧩 | **Multi-printer** — multiple printers in **one** bridge instance, switch via dropdown |
|
||||||
| ➕ | **Add a printer with one click** — just enter the IP, credentials are imported automatically |
|
| ➕ | **Add a printer with one click** — just enter the IP, credentials are imported automatically |
|
||||||
| 🔁 | **Robust MQTT reconnect** — bridge survives overnight printer reboots without manual restart |
|
| 🔁 | **Robust MQTT reconnect** — bridge survives overnight printer reboots without manual restart |
|
||||||
| 🌐 | **Multi-language UI** — DE / EN / ES / 中文, auto-detect browser locale |
|
| 🌐 | **Multi-language UI** — DE / EN / ES / FR / IT / 中文, auto-detect browser locale |
|
||||||
| 🔄 | **Self-update** — install new versions directly in the browser |
|
| 🔄 | **Self-update** — install new versions directly in the browser |
|
||||||
| 🧠 | **OrcaSlicer** — full Moonraker protocol (HTTP + WebSocket); pair with the [OrcaSlicer-KX build](#-recommended-slicer) for proper per-slot vendor matching |
|
| 🧠 | **OrcaSlicer** — full Moonraker protocol (HTTP + WebSocket); pair with the [OrcaSlicer-KX build](#-recommended-slicer) for proper per-slot vendor matching |
|
||||||
|
|
||||||
@@ -182,6 +187,24 @@ docker compose up -d --build # rebuild locally (instead of pulling)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🌙 Nightly Builds
|
||||||
|
|
||||||
|
Nightly builds contain the latest unreleased features and are built automatically on every development push.
|
||||||
|
They may be unstable — use them for testing or early access to new functionality.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.nightly.yml pull
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.nightly.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
To go back to the stable release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🩹 Troubleshooting
|
## 🩹 Troubleshooting
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
31
agents.md
Normal file
31
agents.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# KX-Bridge Claude Agents
|
||||||
|
|
||||||
|
## Available Agents
|
||||||
|
|
||||||
|
| Agent | File | When to use |
|
||||||
|
|---|---|---|
|
||||||
|
| Reviewer | `.claude/agents/reviewer.md` | Before every PR — checks logic, error handling, Moonraker compatibility |
|
||||||
|
| Changelog | `.claude/agents/changelog.md` | After merge to nightly — generates CHANGELOG.md entry from commits |
|
||||||
|
| Test Writer | `.claude/agents/test-writer.md` | When adding new functions — derives pytest tests |
|
||||||
|
| Nightly Prep | `.claude/agents/nightly-prep.md` | Before a release — checks readiness of nightly → main merge |
|
||||||
|
| Docker Check | `.claude/agents/docker-check.md` | Before image push — validates Dockerfile and compose config |
|
||||||
|
| Moonraker Debug | `.claude/agents/moonraker-debug.md` | On runtime errors — analyzes Moonraker/Klipper logs |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
In VS Code with Claude Code extension:
|
||||||
|
```
|
||||||
|
@reviewer → code review of current changes
|
||||||
|
@changelog → generate CHANGELOG entry
|
||||||
|
@test-writer → write tests for changed files
|
||||||
|
@nightly-prep → check release readiness
|
||||||
|
@docker-check → validate Docker config
|
||||||
|
@moonraker-debug → analyze logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Moonraker API: Port 7125
|
||||||
|
- AFC lane_data: flat indexing lane1–lane4
|
||||||
|
- Registry: `gitea.it-drui.de/viewit/kx-bridge`
|
||||||
|
- Default PR target: `nightly`
|
||||||
@@ -31,6 +31,88 @@ default_ams_slot = auto
|
|||||||
# Auto-Leveling vor jedem Druck (1 = an, 0 = aus)
|
# Auto-Leveling vor jedem Druck (1 = an, 0 = aus)
|
||||||
auto_leveling = 1
|
auto_leveling = 1
|
||||||
|
|
||||||
|
# Kamera-Stream bei Druckstart automatisch einschalten (1 = an, 0 = aus)
|
||||||
|
camera_on_print = 0
|
||||||
|
|
||||||
|
# Warnung vor Druck von Web-Uploads (1 = an, 0 = aus)
|
||||||
|
web_upload_warning = 1
|
||||||
|
|
||||||
|
# Nach Upload: Filament/Color-Selector automatisch öffnen (1 = an, 0 = aus)
|
||||||
|
print_start_dialog = 1
|
||||||
|
|
||||||
|
# ─── Filament-Profile pro AMS-Slot (optional) ────────────────────────────────
|
||||||
|
# Beim Slicer-Sync nimmt OrcaSlicer per Default immer "Generic PLA/PETG/...".
|
||||||
|
# Mit diesen Mappings sendet die Bridge die konkrete Orca-Filament-ID +
|
||||||
|
# Vendor mit (Anzeige im Slicer dann z.B. "PolyTerra PLA — Polymaker" statt
|
||||||
|
# nur "Generic PLA"). Mapping wird über die Web-UI gepflegt.
|
||||||
|
# Beispiel:
|
||||||
|
# [filament_profiles]
|
||||||
|
# slot_0_id = OGFL01
|
||||||
|
# slot_0_vendor = Polymaker
|
||||||
|
# slot_1_id = OGFG23
|
||||||
|
# slot_1_vendor = Polymaker
|
||||||
|
|
||||||
[bridge]
|
[bridge]
|
||||||
# Poll-Intervall in Sekunden
|
# Poll-Intervall in Sekunden
|
||||||
poll_interval = 3
|
poll_interval = 3
|
||||||
|
|
||||||
|
# ─── Multi-Printer (optional) ──────────────────────────────────────────────────
|
||||||
|
# Mehrere Drucker können als [printer_1], [printer_2], … definiert werden.
|
||||||
|
# Jede Bridge-Instanz verbindet sich mit einem Drucker (je eigener Port).
|
||||||
|
# bridge_url zeigt auf die jeweilige Bridge-Instanz (für den /kx/printers-Endpunkt).
|
||||||
|
# Die [connection]-Sektion wird weiterhin als Fallback für diese Instanz verwendet.
|
||||||
|
#
|
||||||
|
# Beispiel:
|
||||||
|
# [printer_1]
|
||||||
|
# name = Kobra X Links
|
||||||
|
# bridge_url = http://192.168.178.95:7125
|
||||||
|
# printer_ip = 192.168.178.95
|
||||||
|
# mqtt_port = 9883
|
||||||
|
# username = userXXXXXXXXXX
|
||||||
|
# password = XXXXXXXXXXXXXXX
|
||||||
|
# device_id = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
# mode_id = 20030
|
||||||
|
#
|
||||||
|
# [printer_2]
|
||||||
|
# name = Kobra X Rechts
|
||||||
|
# bridge_url = http://192.168.178.96:7125
|
||||||
|
# printer_ip = 192.168.178.96
|
||||||
|
# mqtt_port = 9883
|
||||||
|
# username = userYYYYYYYYYY
|
||||||
|
# password = YYYYYYYYYYYYYYY
|
||||||
|
# device_id = yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
|
||||||
|
# mode_id = 20030
|
||||||
|
|
||||||
|
[ace_dry_presets]
|
||||||
|
# Vordefinierte Dry-Set Presets (Temp in °C, Dauer in Sekunden)
|
||||||
|
pla_temp = 45
|
||||||
|
pla_duration_sec = 14400
|
||||||
|
pla_plus_temp = 45
|
||||||
|
pla_plus_duration_sec = 14400
|
||||||
|
petg_temp = 50
|
||||||
|
petg_duration_sec = 14400
|
||||||
|
tpu_temp = 55
|
||||||
|
tpu_duration_sec = 14400
|
||||||
|
abs_asa_temp = 45
|
||||||
|
abs_asa_duration_sec = 28800
|
||||||
|
pa_pc_temp = 55
|
||||||
|
pa_pc_duration_sec = 43200
|
||||||
|
|
||||||
|
# Custom Presets (Name + Temp + Dauer)
|
||||||
|
custom_1_name = Custom 1
|
||||||
|
custom_1_temp = 45
|
||||||
|
custom_1_duration_sec = 14400
|
||||||
|
custom_2_name = Custom 2
|
||||||
|
custom_2_temp = 45
|
||||||
|
custom_2_duration_sec = 14400
|
||||||
|
custom_3_name = Custom 3
|
||||||
|
custom_3_temp = 45
|
||||||
|
custom_3_duration_sec = 14400
|
||||||
|
|
||||||
|
[spoolman]
|
||||||
|
# URL der Spoolman-Instanz (leer lassen um Spoolman zu deaktivieren)
|
||||||
|
# server = http://192.168.x.x:7912
|
||||||
|
|
||||||
|
# Wie oft (Sekunden) der Filamentverbrauch während des Drucks gemeldet wird
|
||||||
|
# (0 = nur beim Druckende)
|
||||||
|
# sync_rate = 0
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import pathlib
|
import pathlib
|
||||||
import configparser
|
import configparser
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
_BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) else pathlib.Path(__file__).parent
|
_BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) else pathlib.Path(__file__).parent
|
||||||
|
|
||||||
@@ -182,9 +183,27 @@ def list_printers() -> list[dict]:
|
|||||||
return printers
|
return printers
|
||||||
|
|
||||||
|
|
||||||
def list_filament_profiles() -> dict[int, dict]:
|
def _filament_section(printer_id: Optional[str] = None) -> str:
|
||||||
|
"""Section name holding a printer's filament-profile mapping.
|
||||||
|
|
||||||
|
Multi-printer (one bridge, N printers): each printer keeps its own
|
||||||
|
``[filament_profiles_<id>]`` section so the mappings cannot overwrite each
|
||||||
|
other. ``printer_id is None`` (single-printer / legacy callers) maps to the
|
||||||
|
original global ``[filament_profiles]`` section — full backward compatibility.
|
||||||
|
"""
|
||||||
|
pid = str(printer_id).strip() if printer_id is not None else ""
|
||||||
|
if pid and pid != "0":
|
||||||
|
return f"filament_profiles_{pid}"
|
||||||
|
return "filament_profiles"
|
||||||
|
|
||||||
|
|
||||||
|
def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
|
||||||
"""Liest die [filament_profiles]-Sektion aus config.ini.
|
"""Liest die [filament_profiles]-Sektion aus config.ini.
|
||||||
|
|
||||||
|
With ``printer_id`` set, reads the per-printer ``[filament_profiles_<id>]``
|
||||||
|
section and falls back to the legacy global ``[filament_profiles]`` while
|
||||||
|
that printer has no own section yet.
|
||||||
|
|
||||||
Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird
|
Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird
|
||||||
aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt
|
aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt
|
||||||
(als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit
|
(als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit
|
||||||
@@ -208,10 +227,13 @@ def list_filament_profiles() -> dict[int, dict]:
|
|||||||
return {}
|
return {}
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser()
|
||||||
cfg.read(path, encoding="utf-8")
|
cfg.read(path, encoding="utf-8")
|
||||||
if not cfg.has_section("filament_profiles"):
|
section = _filament_section(printer_id)
|
||||||
|
if not cfg.has_section(section):
|
||||||
|
section = "filament_profiles" # fallback: legacy global section
|
||||||
|
if not cfg.has_section(section):
|
||||||
return {}
|
return {}
|
||||||
result: dict[int, dict] = {}
|
result: dict[int, dict] = {}
|
||||||
for key, value in cfg.items("filament_profiles"):
|
for key, value in cfg.items(section):
|
||||||
# Erwartet: slot_<idx>_id oder slot_<idx>_vendor oder slot_<idx>_name
|
# Erwartet: slot_<idx>_id oder slot_<idx>_vendor oder slot_<idx>_name
|
||||||
if not key.startswith("slot_"):
|
if not key.startswith("slot_"):
|
||||||
continue
|
continue
|
||||||
@@ -231,74 +253,97 @@ def list_filament_profiles() -> dict[int, dict]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def save_filament_profiles(profiles: dict[int, dict]) -> bool:
|
def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] = None) -> bool:
|
||||||
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
|
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
|
||||||
Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
|
Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
|
||||||
|
|
||||||
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}}
|
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}}
|
||||||
Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint).
|
Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint).
|
||||||
|
|
||||||
|
With ``printer_id`` set, writes the per-printer ``[filament_profiles_<id>]``
|
||||||
|
section only — other printers and the legacy global section are untouched.
|
||||||
"""
|
"""
|
||||||
path = _find_config_file()
|
path = _find_config_file()
|
||||||
if not path:
|
if not path:
|
||||||
return False
|
return False
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser()
|
||||||
cfg.read(path, encoding="utf-8")
|
cfg.read(path, encoding="utf-8")
|
||||||
|
section = _filament_section(printer_id)
|
||||||
# visible_vendors (Issue #41) ist kein Slot-Mapping — beim Ersetzen der
|
# visible_vendors (Issue #41) ist kein Slot-Mapping — beim Ersetzen der
|
||||||
# Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren.
|
# Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren.
|
||||||
|
# First save of a per-printer section inherits the legacy global filter.
|
||||||
preserved_vendors = None
|
preserved_vendors = None
|
||||||
if cfg.has_option("filament_profiles", "visible_vendors"):
|
if cfg.has_option(section, "visible_vendors"):
|
||||||
|
preserved_vendors = cfg.get(section, "visible_vendors")
|
||||||
|
elif cfg.has_option("filament_profiles", "visible_vendors"):
|
||||||
preserved_vendors = cfg.get("filament_profiles", "visible_vendors")
|
preserved_vendors = cfg.get("filament_profiles", "visible_vendors")
|
||||||
if cfg.has_section("filament_profiles"):
|
if cfg.has_section(section):
|
||||||
cfg.remove_section("filament_profiles")
|
cfg.remove_section(section)
|
||||||
if profiles or preserved_vendors:
|
if profiles or preserved_vendors:
|
||||||
cfg["filament_profiles"] = {}
|
cfg[section] = {}
|
||||||
if preserved_vendors:
|
if preserved_vendors:
|
||||||
cfg["filament_profiles"]["visible_vendors"] = preserved_vendors
|
cfg[section]["visible_vendors"] = preserved_vendors
|
||||||
for slot_idx in sorted(profiles.keys()):
|
for slot_idx in sorted(profiles.keys()):
|
||||||
entry = profiles[slot_idx] or {}
|
entry = profiles[slot_idx] or {}
|
||||||
if entry.get("vendor"):
|
if entry.get("vendor"):
|
||||||
cfg["filament_profiles"][f"slot_{slot_idx}_vendor"] = entry["vendor"]
|
cfg[section][f"slot_{slot_idx}_vendor"] = entry["vendor"]
|
||||||
if entry.get("name"):
|
if entry.get("name"):
|
||||||
cfg["filament_profiles"][f"slot_{slot_idx}_name"] = entry["name"]
|
cfg[section][f"slot_{slot_idx}_name"] = entry["name"]
|
||||||
if entry.get("id"):
|
if entry.get("id"):
|
||||||
cfg["filament_profiles"][f"slot_{slot_idx}_id"] = entry["id"]
|
cfg[section][f"slot_{slot_idx}_id"] = entry["id"]
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
cfg.write(f)
|
cfg.write(f)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def list_visible_vendors() -> list[str]:
|
def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]:
|
||||||
"""Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini.
|
"""Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini.
|
||||||
|
|
||||||
Vendor-Sichtbarkeitsfilter für das Slot-Profil-Dropdown (Issue #41 Option A).
|
Vendor-Sichtbarkeitsfilter für das Slot-Profil-Dropdown (Issue #41 Option A).
|
||||||
Leere Liste = keine Einschränkung (rückwärtskompatibel: alle Vendoren).
|
Leere Liste = keine Einschränkung (rückwärtskompatibel: alle Vendoren).
|
||||||
|
|
||||||
|
With ``printer_id`` set, reads the per-printer section and falls back to the
|
||||||
|
legacy global ``[filament_profiles]`` filter.
|
||||||
"""
|
"""
|
||||||
path = _find_config_file()
|
path = _find_config_file()
|
||||||
if not path:
|
if not path:
|
||||||
return []
|
return []
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser()
|
||||||
cfg.read(path, encoding="utf-8")
|
cfg.read(path, encoding="utf-8")
|
||||||
if not cfg.has_option("filament_profiles", "visible_vendors"):
|
section = _filament_section(printer_id)
|
||||||
|
if not cfg.has_option(section, "visible_vendors"):
|
||||||
|
section = "filament_profiles" # fallback: legacy global section
|
||||||
|
if not cfg.has_option(section, "visible_vendors"):
|
||||||
return []
|
return []
|
||||||
raw = cfg.get("filament_profiles", "visible_vendors")
|
raw = cfg.get(section, "visible_vendors")
|
||||||
return [v.strip() for v in raw.split(",") if v.strip()]
|
return [v.strip() for v in raw.split(",") if v.strip()]
|
||||||
|
|
||||||
|
|
||||||
def save_visible_vendors(vendors: list[str]) -> bool:
|
def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -> bool:
|
||||||
"""Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings
|
"""Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings
|
||||||
(slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder."""
|
(slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder.
|
||||||
|
|
||||||
|
With ``printer_id`` set, writes the per-printer section. When that section is
|
||||||
|
created here for the first time, the slot mappings are seeded from the legacy
|
||||||
|
global section so they are not orphaned by the read-fallback in
|
||||||
|
``list_filament_profiles``."""
|
||||||
path = _find_config_file()
|
path = _find_config_file()
|
||||||
if not path:
|
if not path:
|
||||||
return False
|
return False
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser()
|
||||||
cfg.read(path, encoding="utf-8")
|
cfg.read(path, encoding="utf-8")
|
||||||
if not cfg.has_section("filament_profiles"):
|
section = _filament_section(printer_id)
|
||||||
cfg.add_section("filament_profiles")
|
if not cfg.has_section(section):
|
||||||
|
cfg.add_section(section)
|
||||||
|
if section != "filament_profiles" and cfg.has_section("filament_profiles"):
|
||||||
|
for key, value in cfg.items("filament_profiles"):
|
||||||
|
if key.startswith("slot_"):
|
||||||
|
cfg[section][key] = value
|
||||||
clean = [v.strip() for v in (vendors or []) if v and v.strip()]
|
clean = [v.strip() for v in (vendors or []) if v and v.strip()]
|
||||||
if clean:
|
if clean:
|
||||||
cfg["filament_profiles"]["visible_vendors"] = ", ".join(clean)
|
cfg[section]["visible_vendors"] = ", ".join(clean)
|
||||||
elif cfg.has_option("filament_profiles", "visible_vendors"):
|
elif cfg.has_option(section, "visible_vendors"):
|
||||||
cfg.remove_option("filament_profiles", "visible_vendors")
|
cfg.remove_option(section, "visible_vendors")
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
cfg.write(f)
|
cfg.write(f)
|
||||||
return True
|
return True
|
||||||
|
|||||||
210
docker-compose-KX.yml
Normal file
210
docker-compose-KX.yml
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# KobraX Full Stack — KX-Bridge + Obico Self-Hosted + Spoolman
|
||||||
|
#
|
||||||
|
# Für Portainer: Stack → Add Stack → Upload → diese Datei wählen
|
||||||
|
#
|
||||||
|
# Voraussetzung: Obico-Images einmalig in Gitea-Registry pushen:
|
||||||
|
# docker tag obico-server-web:latest gitea.it-drui.de/viewit/obico-web:latest
|
||||||
|
# docker tag obico-server-ml_api:latest gitea.it-drui.de/viewit/obico-ml:latest
|
||||||
|
# docker tag obico-server-tasks:latest gitea.it-drui.de/viewit/obico-tasks:latest
|
||||||
|
# docker push gitea.it-drui.de/viewit/obico-web:latest
|
||||||
|
# docker push gitea.it-drui.de/viewit/obico-ml:latest
|
||||||
|
# docker push gitea.it-drui.de/viewit/obico-tasks:latest
|
||||||
|
#
|
||||||
|
# Persistente Daten: /mnt/dockerdata/KobraXStack/<service>/
|
||||||
|
#
|
||||||
|
# Ports:
|
||||||
|
# 7125 — KX-Bridge (Moonraker-API)
|
||||||
|
# 3334 — Obico (Web-UI)
|
||||||
|
# 7912 — Spoolman (Web-UI)
|
||||||
|
#
|
||||||
|
# Obico Admin-Account nach dem ersten Start:
|
||||||
|
# docker exec obico-web python manage.py createsuperuser
|
||||||
|
|
||||||
|
x-obico-base: &obico-base
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /mnt/dockerdata/KobraXStack/obico/data:/data
|
||||||
|
- /mnt/dockerdata/KobraXStack/obico/frontend:/frontend
|
||||||
|
depends_on:
|
||||||
|
- obico-redis
|
||||||
|
environment:
|
||||||
|
DEBUG: "False"
|
||||||
|
REDIS_URL: "redis://obico-redis:6379"
|
||||||
|
DATABASE_URL: "sqlite:////data/db.sqlite3"
|
||||||
|
INTERNAL_MEDIA_HOST: "http://obico-web:3334"
|
||||||
|
ML_API_HOST: "http://obico-ml:3333"
|
||||||
|
ACCOUNT_ALLOW_SIGN_UP: "False"
|
||||||
|
SITE_USES_HTTPS: "False"
|
||||||
|
SITE_IS_PUBLIC: "False"
|
||||||
|
DJANGO_SECRET_KEY: "change-me-to-a-random-secret-key-before-use"
|
||||||
|
WEBPACK_LOADER_ENABLED: "False"
|
||||||
|
networks:
|
||||||
|
- kobrax-stack
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ── KX-Bridge ───────────────────────────────────────────────
|
||||||
|
kx-bridge:
|
||||||
|
image: gitea.it-drui.de/viewit/kx-bridge:latest
|
||||||
|
container_name: kx-bridge
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "7125:7125"
|
||||||
|
volumes:
|
||||||
|
- /mnt/dockerdata/KobraXStack/kx-bridge/config:/app/config
|
||||||
|
- /mnt/dockerdata/KobraXStack/kx-bridge/data:/app/data
|
||||||
|
networks:
|
||||||
|
- kobrax-stack
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ── Spoolman ────────────────────────────────────────────────
|
||||||
|
spoolman:
|
||||||
|
image: ghcr.io/donkie/spoolman:latest
|
||||||
|
container_name: spoolman
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "7912:8000"
|
||||||
|
volumes:
|
||||||
|
- /mnt/dockerdata/KobraXStack/spoolman:/home/app/.local/share/spoolman
|
||||||
|
networks:
|
||||||
|
- kobrax-stack
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ── Obico Redis ─────────────────────────────────────────────
|
||||||
|
obico-redis:
|
||||||
|
image: redis:7.2-alpine
|
||||||
|
container_name: obico-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /mnt/dockerdata/KobraXStack/obico/redis:/data
|
||||||
|
networks:
|
||||||
|
- kobrax-stack
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
start_period: 10s
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "5m"
|
||||||
|
max-file: "2"
|
||||||
|
|
||||||
|
# ── Obico ML API ────────────────────────────────────────────
|
||||||
|
obico-ml:
|
||||||
|
image: gitea.it-drui.de/viewit/obico-ml:latest
|
||||||
|
container_name: obico-ml
|
||||||
|
restart: unless-stopped
|
||||||
|
command: bash -c "gunicorn --bind 0.0.0.0:3333 --workers 1 wsgi"
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
DEBUG: "False"
|
||||||
|
FLASK_APP: "server.py"
|
||||||
|
networks:
|
||||||
|
- kobrax-stack
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3333/hc/ || exit 1"]
|
||||||
|
start_period: 30s
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ── Obico Web ───────────────────────────────────────────────
|
||||||
|
obico-web:
|
||||||
|
<<: *obico-base
|
||||||
|
image: gitea.it-drui.de/viewit/obico-web:latest
|
||||||
|
container_name: obico-web
|
||||||
|
ports:
|
||||||
|
- "3334:3334"
|
||||||
|
depends_on:
|
||||||
|
- obico-ml
|
||||||
|
- obico-redis
|
||||||
|
command: >
|
||||||
|
sh -c 'python manage.py migrate &&
|
||||||
|
python manage.py shell -c "from django.contrib.sites.models import Site; s=Site.objects.first(); s.domain=\"192.168.178.204:3334\"; s.name=\"Obico\"; s.save()" &&
|
||||||
|
python manage.py collectstatic --noinput &&
|
||||||
|
daphne -b 0.0.0.0 -p 3334 config.routing:application'
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3334/hc/ || exit 1"]
|
||||||
|
start_period: 60s
|
||||||
|
interval: 90s
|
||||||
|
timeout: 20s
|
||||||
|
retries: 3
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ── Obico Tasks (Celery) ────────────────────────────────────
|
||||||
|
obico-tasks:
|
||||||
|
<<: *obico-base
|
||||||
|
image: gitea.it-drui.de/viewit/obico-tasks:latest
|
||||||
|
container_name: obico-tasks
|
||||||
|
command: sh -c "celery -A config worker --beat -l info -c 2 -Q realtime,celery"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ── moonraker-obico Plugin ──────────────────────────────────
|
||||||
|
# Verbindet KX-Bridge mit dem Obico-Server (Spaghetti-Detektion, Remote-UI)
|
||||||
|
# Voraussetzung: /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg
|
||||||
|
# muss existieren und einen gültigen auth_token enthalten.
|
||||||
|
#
|
||||||
|
# Token holen (nach erstem obico-web Start):
|
||||||
|
# docker exec obico-web python manage.py shell -c "
|
||||||
|
# from app.models import OneTimeVerificationCode, User
|
||||||
|
# from django.utils import timezone; from datetime import timedelta; import random
|
||||||
|
# u = User.objects.first()
|
||||||
|
# c = OneTimeVerificationCode.objects.create(user=u, code='%06d' % random.randint(100000,999999), expired_at=timezone.now()+timedelta(hours=2))
|
||||||
|
# print('CODE:', c.code)"
|
||||||
|
# curl -X POST 'http://localhost:3334/api/v1/octo/verify/?code=<CODE>'
|
||||||
|
# → printer.auth_token aus der Antwort in die cfg eintragen
|
||||||
|
moonraker-obico:
|
||||||
|
image: gitea.it-drui.de/viewit/moonraker-obico:latest
|
||||||
|
container_name: moonraker-obico
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- /mnt/dockerdata/KobraXStack/moonraker-obico:/opt/printer_data/config
|
||||||
|
- /mnt/dockerdata/KobraXStack/moonraker-obico/logs:/opt/printer_data/logs
|
||||||
|
command: ["-c", "/opt/printer_data/config/moonraker-obico.cfg"]
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
kobrax-stack:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
# Verzeichnisse müssen auf dem Host existieren:
|
||||||
|
# mkdir -p /mnt/dockerdata/KobraXStack/kx-bridge/config \
|
||||||
|
# /mnt/dockerdata/KobraXStack/kx-bridge/data \
|
||||||
|
# /mnt/dockerdata/KobraXStack/spoolman \
|
||||||
|
# /mnt/dockerdata/KobraXStack/obico/data \
|
||||||
|
# /mnt/dockerdata/KobraXStack/obico/frontend \
|
||||||
|
# /mnt/dockerdata/KobraXStack/obico/redis \
|
||||||
|
# /mnt/dockerdata/KobraXStack/moonraker-obico/logs
|
||||||
|
# Spoolman benötigt UID/GID 1000:
|
||||||
|
# sudo chown -R 1000:1000 /mnt/dockerdata/KobraXStack/spoolman
|
||||||
|
#
|
||||||
|
# moonraker-obico Config anlegen (auth_token nach Obico-Setup eintragen):
|
||||||
|
# cp /path/to/moonraker-obico.cfg.example /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg
|
||||||
@@ -142,6 +142,11 @@ _KX_UI_ASSETS: dict[str, str] = {
|
|||||||
"style.css": "text/css",
|
"style.css": "text/css",
|
||||||
"app.js": "application/javascript",
|
"app.js": "application/javascript",
|
||||||
}
|
}
|
||||||
|
# Dateien aus lib/ werden anhand der Extension ausgeliefert (kein Whitelist-Eintrag nötig)
|
||||||
|
_KX_UI_LIB_TYPES: dict[str, str] = {
|
||||||
|
".js": "application/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
}
|
||||||
_KX_UI_TRANSLATION_RE = re.compile(r"^translations/([a-z]{2}(?:-[a-z]{2})?)\.json$")
|
_KX_UI_TRANSLATION_RE = re.compile(r"^translations/([a-z]{2}(?:-[a-z]{2})?)\.json$")
|
||||||
|
|
||||||
# Ring-Buffer für Browser-Log-Stream (letzte 200 Einträge)
|
# Ring-Buffer für Browser-Log-Stream (letzte 200 Einträge)
|
||||||
@@ -602,10 +607,23 @@ class CameraCache:
|
|||||||
self._task_jpeg: "asyncio.Task | None" = None
|
self._task_jpeg: "asyncio.Task | None" = None
|
||||||
self._task_h264: "asyncio.Task | None" = None
|
self._task_h264: "asyncio.Task | None" = None
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
self._fail_count_jpeg: int = 0
|
||||||
|
self._fail_count_h264: int = 0
|
||||||
|
|
||||||
def set_url(self, url: str):
|
def set_url(self, url: str):
|
||||||
self._url = url
|
self._url = url
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Backoff-Zähler zurücksetzen und laufende ffmpeg-Prozesse killen."""
|
||||||
|
self._fail_count_jpeg = 0
|
||||||
|
self._fail_count_h264 = 0
|
||||||
|
for proc in (self._proc_jpeg, self._proc_h264):
|
||||||
|
if proc is not None:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def ensure_running(self):
|
async def ensure_running(self):
|
||||||
if self._proc_jpeg is None or self._proc_jpeg.returncode is not None:
|
if self._proc_jpeg is None or self._proc_jpeg.returncode is not None:
|
||||||
self._task_jpeg = asyncio.create_task(self._run_jpeg_loop())
|
self._task_jpeg = asyncio.create_task(self._run_jpeg_loop())
|
||||||
@@ -629,13 +647,13 @@ class CameraCache:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
self._proc_jpeg = await asyncio.create_subprocess_exec(
|
self._proc_jpeg = await asyncio.create_subprocess_exec(
|
||||||
_find_ffmpeg(), "-loglevel", "quiet",
|
_find_ffmpeg(), "-loglevel", "warning",
|
||||||
*self._input_args(url), "-i", url,
|
*self._input_args(url), "-i", url,
|
||||||
"-vf", "fps=2",
|
"-vf", "fps=2",
|
||||||
"-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3",
|
"-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3",
|
||||||
"-flush_packets", "1", "pipe:1",
|
"-flush_packets", "1", "pipe:1",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"CameraCache: ffmpeg-jpeg start fehlgeschlagen: {e}")
|
log.warning(f"CameraCache: ffmpeg-jpeg start fehlgeschlagen: {e}")
|
||||||
@@ -643,6 +661,7 @@ class CameraCache:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
buf = b""
|
buf = b""
|
||||||
|
rc = None
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
chunk = await self._proc_jpeg.stdout.read(self.TS_CHUNK)
|
chunk = await self._proc_jpeg.stdout.read(self.TS_CHUNK)
|
||||||
@@ -676,8 +695,23 @@ class CameraCache:
|
|||||||
await self._proc_jpeg.wait()
|
await self._proc_jpeg.wait()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
rc = self._proc_jpeg.returncode
|
||||||
|
if rc:
|
||||||
|
try:
|
||||||
|
err = await self._proc_jpeg.stderr.read(500)
|
||||||
|
if err:
|
||||||
|
log.warning(f"CameraCache: ffmpeg-jpeg stderr: {err.decode(errors='replace').strip()}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._proc_jpeg = None
|
self._proc_jpeg = None
|
||||||
await asyncio.sleep(2.0) # restart delay
|
if rc:
|
||||||
|
self._fail_count_jpeg += 1
|
||||||
|
delay = min(2.0 * (2 ** self._fail_count_jpeg), 300.0)
|
||||||
|
log.warning(f"CameraCache: ffmpeg-jpeg exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_jpeg})")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
else:
|
||||||
|
self._fail_count_jpeg = 0
|
||||||
|
await asyncio.sleep(2.0)
|
||||||
|
|
||||||
async def _run_h264_loop(self):
|
async def _run_h264_loop(self):
|
||||||
"""Hält einen ffmpeg-Prozess am Leben der MPEG-TS an alle Subscriber fanoutet."""
|
"""Hält einen ffmpeg-Prozess am Leben der MPEG-TS an alle Subscriber fanoutet."""
|
||||||
@@ -688,18 +722,19 @@ class CameraCache:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
self._proc_h264 = await asyncio.create_subprocess_exec(
|
self._proc_h264 = await asyncio.create_subprocess_exec(
|
||||||
_find_ffmpeg(), "-loglevel", "quiet",
|
_find_ffmpeg(), "-loglevel", "warning",
|
||||||
*self._input_args(url), "-i", url,
|
*self._input_args(url), "-i", url,
|
||||||
"-c:v", "copy", "-an",
|
"-c:v", "copy", "-an",
|
||||||
"-f", "mpegts", "pipe:1",
|
"-f", "mpegts", "pipe:1",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"CameraCache: ffmpeg-h264 start fehlgeschlagen: {e}")
|
log.warning(f"CameraCache: ffmpeg-h264 start fehlgeschlagen: {e}")
|
||||||
await asyncio.sleep(3.0)
|
await asyncio.sleep(3.0)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
rc = None
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
chunk = await self._proc_h264.stdout.read(self.TS_CHUNK)
|
chunk = await self._proc_h264.stdout.read(self.TS_CHUNK)
|
||||||
@@ -729,8 +764,23 @@ class CameraCache:
|
|||||||
await self._proc_h264.wait()
|
await self._proc_h264.wait()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
rc = self._proc_h264.returncode
|
||||||
|
if rc:
|
||||||
|
try:
|
||||||
|
err = await self._proc_h264.stderr.read(500)
|
||||||
|
if err:
|
||||||
|
log.warning(f"CameraCache: ffmpeg-h264 stderr: {err.decode(errors='replace').strip()}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._proc_h264 = None
|
self._proc_h264 = None
|
||||||
await asyncio.sleep(2.0)
|
if rc:
|
||||||
|
self._fail_count_h264 += 1
|
||||||
|
delay = min(2.0 * (2 ** self._fail_count_h264), 300.0)
|
||||||
|
log.warning(f"CameraCache: ffmpeg-h264 exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_h264})")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
else:
|
||||||
|
self._fail_count_h264 = 0
|
||||||
|
await asyncio.sleep(2.0)
|
||||||
|
|
||||||
|
|
||||||
class SpoolmanClient:
|
class SpoolmanClient:
|
||||||
@@ -783,14 +833,14 @@ class KobraXBridge:
|
|||||||
# Marke ("PolyTerra PLA — Polymaker") statt nur "Generic PLA" anzeigt.
|
# Marke ("PolyTerra PLA — Polymaker") statt nur "Generic PLA" anzeigt.
|
||||||
try:
|
try:
|
||||||
import config_loader as _cl
|
import config_loader as _cl
|
||||||
self._filament_profiles: dict[int, dict] = _cl.list_filament_profiles()
|
self._filament_profiles: dict[int, dict] = _cl.list_filament_profiles(self._printer_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._filament_profiles = {}
|
self._filament_profiles = {}
|
||||||
# Vendor-Sichtbarkeitsfilter fürs Slot-Profil-Dropdown (Issue #41 Option A).
|
# Vendor-Sichtbarkeitsfilter fürs Slot-Profil-Dropdown (Issue #41 Option A).
|
||||||
# Leere Liste = alle Vendoren sichtbar (rückwärtskompatibel).
|
# Leere Liste = alle Vendoren sichtbar (rückwärtskompatibel).
|
||||||
try:
|
try:
|
||||||
import config_loader as _cl
|
import config_loader as _cl
|
||||||
self._visible_vendors: list[str] = _cl.list_visible_vendors()
|
self._visible_vendors: list[str] = _cl.list_visible_vendors(self._printer_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._visible_vendors = []
|
self._visible_vendors = []
|
||||||
self._last_state: dict = {}
|
self._last_state: dict = {}
|
||||||
@@ -825,6 +875,7 @@ class KobraXBridge:
|
|||||||
"print_speed_mode": 2,
|
"print_speed_mode": 2,
|
||||||
"connection_error": "",
|
"connection_error": "",
|
||||||
"file_ready": "",
|
"file_ready": "",
|
||||||
|
"filament_mismatch": None,
|
||||||
"print_start_dialog": getattr(args, "print_start_dialog", 1),
|
"print_start_dialog": getattr(args, "print_start_dialog", 1),
|
||||||
"filament_mode": "toolhead",
|
"filament_mode": "toolhead",
|
||||||
"supplies_usage": 0,
|
"supplies_usage": 0,
|
||||||
@@ -860,7 +911,23 @@ class KobraXBridge:
|
|||||||
SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0))
|
SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0))
|
||||||
if _sm_url else None
|
if _sm_url else None
|
||||||
)
|
)
|
||||||
self._spoolman_slot_spools: dict[int, int] = {} # {ams_slot_idx: spoolman_spool_id}
|
# Persistierte Spool-Zuordnung aus config.ini laden
|
||||||
|
_slot_spools_init: dict[int, int] = {}
|
||||||
|
try:
|
||||||
|
import configparser as _cp2
|
||||||
|
_cfg_path2 = config_loader._find_config_file()
|
||||||
|
if _cfg_path2:
|
||||||
|
_cfg2 = _cp2.ConfigParser()
|
||||||
|
_cfg2.read(_cfg_path2, encoding="utf-8")
|
||||||
|
_raw = _cfg2.get("spoolman", "slot_spools", fallback="")
|
||||||
|
for _pair in _raw.split(","):
|
||||||
|
if ":" in _pair:
|
||||||
|
_k, _v = _pair.strip().split(":", 1)
|
||||||
|
if _k.isdigit() and _v.isdigit():
|
||||||
|
_slot_spools_init[int(_k)] = int(_v)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._spoolman_slot_spools: dict[int, int] = _slot_spools_init # {ams_slot_idx: spoolman_spool_id}
|
||||||
self._spoolman_slot_usage: dict[int, float] = {} # per-slot accumulated mm this print
|
self._spoolman_slot_usage: dict[int, float] = {} # per-slot accumulated mm this print
|
||||||
self._spoolman_slot_reported: dict[int, float] = {} # per-slot mm already sent to Spoolman
|
self._spoolman_slot_reported: dict[int, float] = {} # per-slot mm already sent to Spoolman
|
||||||
self._spoolman_last_usage: float = 0.0 # supplies_usage at last attribution tick
|
self._spoolman_last_usage: float = 0.0 # supplies_usage at last attribution tick
|
||||||
@@ -1007,11 +1074,26 @@ class KobraXBridge:
|
|||||||
data = await request.json()
|
data = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
return self._json_cors({"error": "invalid JSON"}, status=400)
|
return self._json_cors({"error": "invalid JSON"}, status=400)
|
||||||
slot_map = data.get("slot_map") or {}
|
slot_map = data.get("slot_map") or data.get("slot_spools") or {}
|
||||||
self._spoolman_slot_spools = {
|
self._spoolman_slot_spools = {
|
||||||
int(k): int(v) for k, v in slot_map.items()
|
int(k): int(v) for k, v in slot_map.items()
|
||||||
if str(v).isdigit() and int(v) > 0
|
if str(v).isdigit() and int(v) > 0
|
||||||
}
|
}
|
||||||
|
# Persistieren in config.ini damit die Zuordnung Bridge-Neustart überlebt
|
||||||
|
try:
|
||||||
|
import configparser as _cp
|
||||||
|
_cfg_path = config_loader._find_config_file()
|
||||||
|
if _cfg_path:
|
||||||
|
_cfg = _cp.ConfigParser()
|
||||||
|
_cfg.read(_cfg_path, encoding="utf-8")
|
||||||
|
if not _cfg.has_section("spoolman"):
|
||||||
|
_cfg.add_section("spoolman")
|
||||||
|
_cfg.set("spoolman", "slot_spools", ",".join(
|
||||||
|
f"{k}:{v}" for k, v in self._spoolman_slot_spools.items()))
|
||||||
|
with open(_cfg_path, "w", encoding="utf-8") as _f:
|
||||||
|
_cfg.write(_f)
|
||||||
|
except Exception as _e:
|
||||||
|
log.debug(f"Spoolman slot_spools persist error: {_e}")
|
||||||
self._spoolman_slot_usage = {}
|
self._spoolman_slot_usage = {}
|
||||||
self._spoolman_slot_reported = {}
|
self._spoolman_slot_reported = {}
|
||||||
self._spoolman_last_usage = 0.0
|
self._spoolman_last_usage = 0.0
|
||||||
@@ -2555,7 +2637,7 @@ class KobraXBridge:
|
|||||||
# Persistieren in config.ini
|
# Persistieren in config.ini
|
||||||
try:
|
try:
|
||||||
import config_loader as _cl
|
import config_loader as _cl
|
||||||
_cl.save_filament_profiles(self._filament_profiles)
|
_cl.save_filament_profiles(self._filament_profiles, self._printer_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"save_filament_profiles failed: {e}")
|
log.warning(f"save_filament_profiles failed: {e}")
|
||||||
return self._json_cors({"error": str(e)}, status=500)
|
return self._json_cors({"error": str(e)}, status=500)
|
||||||
@@ -2585,7 +2667,7 @@ class KobraXBridge:
|
|||||||
self._visible_vendors = [str(v).strip() for v in vendors if str(v).strip()]
|
self._visible_vendors = [str(v).strip() for v in vendors if str(v).strip()]
|
||||||
try:
|
try:
|
||||||
import config_loader as _cl
|
import config_loader as _cl
|
||||||
_cl.save_visible_vendors(self._visible_vendors)
|
_cl.save_visible_vendors(self._visible_vendors, self._printer_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"save_visible_vendors failed: {e}")
|
log.warning(f"save_visible_vendors failed: {e}")
|
||||||
return self._json_cors({"error": str(e)}, status=500)
|
return self._json_cors({"error": str(e)}, status=500)
|
||||||
@@ -3228,6 +3310,31 @@ class KobraXBridge:
|
|||||||
self._state["last_upload_size"] = file_size
|
self._state["last_upload_size"] = file_size
|
||||||
|
|
||||||
if auto_print:
|
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}")
|
log.info(f"Upload+Print (print=true): {remote_filename}")
|
||||||
self._state["file_ready"] = ""
|
self._state["file_ready"] = ""
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
@@ -3256,6 +3363,45 @@ class KobraXBridge:
|
|||||||
}
|
}
|
||||||
}, status=201)
|
}, 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,
|
def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0,
|
||||||
gcode_filaments: list | None = None):
|
gcode_filaments: list | None = None):
|
||||||
self._state["file_ready"] = ""
|
self._state["file_ready"] = ""
|
||||||
@@ -3447,6 +3593,7 @@ class KobraXBridge:
|
|||||||
|
|
||||||
async def handle_api_file_ready_clear(self, request):
|
async def handle_api_file_ready_clear(self, request):
|
||||||
self._state["file_ready"] = ""
|
self._state["file_ready"] = ""
|
||||||
|
self._state["filament_mismatch"] = None
|
||||||
self._thumbnail_b64 = ""
|
self._thumbnail_b64 = ""
|
||||||
self._push_status_update()
|
self._push_status_update()
|
||||||
return web.json_response({"result": "ok"})
|
return web.json_response({"result": "ok"})
|
||||||
@@ -3465,6 +3612,12 @@ class KobraXBridge:
|
|||||||
|
|
||||||
if ctype is not None:
|
if ctype is not None:
|
||||||
path = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, name)
|
path = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, name)
|
||||||
|
elif name.startswith("lib/"):
|
||||||
|
ext = os.path.splitext(name)[1].lower()
|
||||||
|
ctype = _KX_UI_LIB_TYPES.get(ext)
|
||||||
|
if not ctype:
|
||||||
|
raise web.HTTPNotFound()
|
||||||
|
path = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, name)
|
||||||
else:
|
else:
|
||||||
m = _KX_UI_TRANSLATION_RE.match(name)
|
m = _KX_UI_TRANSLATION_RE.match(name)
|
||||||
if not m:
|
if not m:
|
||||||
@@ -3863,6 +4016,16 @@ class KobraXBridge:
|
|||||||
self._camera_user_stopped = True
|
self._camera_user_stopped = True
|
||||||
return web.json_response({"result": "ok"})
|
return web.json_response({"result": "ok"})
|
||||||
|
|
||||||
|
async def handle_api_camera_reset(self, request):
|
||||||
|
"""Backoff-Zähler zurücksetzen und ffmpeg sofort neu starten.
|
||||||
|
Nützlich nach 429-Sperre (Retry-After abgelaufen) oder nach Drucker-Neustart."""
|
||||||
|
self.camera_cache.reset()
|
||||||
|
url = self._state.get("camera_url", "")
|
||||||
|
if url:
|
||||||
|
self.camera_cache.set_url(url)
|
||||||
|
await self.camera_cache.ensure_running()
|
||||||
|
return web.json_response({"result": "ok"})
|
||||||
|
|
||||||
async def handle_api_camera_snapshot(self, request):
|
async def handle_api_camera_snapshot(self, request):
|
||||||
"""Letzter JPEG-Frame aus dem CameraCache — instant aus dem RAM,
|
"""Letzter JPEG-Frame aus dem CameraCache — instant aus dem RAM,
|
||||||
keine eigene ffmpeg-Instanz mehr (verhindert Single-Client-429 am
|
keine eigene ffmpeg-Instanz mehr (verhindert Single-Client-429 am
|
||||||
@@ -4218,6 +4381,8 @@ class KobraXBridge:
|
|||||||
"filament_profiles": {str(k): v for k, v in self._filament_profiles.items()},
|
"filament_profiles": {str(k): v for k, v in self._filament_profiles.items()},
|
||||||
"visible_vendors": self._visible_vendors,
|
"visible_vendors": self._visible_vendors,
|
||||||
"ace_dry_presets": self._ace_dry_presets,
|
"ace_dry_presets": self._ace_dry_presets,
|
||||||
|
"spoolman_server": getattr(self._args, "spoolman_server", "") or "",
|
||||||
|
"spoolman_sync_rate": getattr(self._args, "spoolman_sync_rate", 0),
|
||||||
})
|
})
|
||||||
|
|
||||||
async def handle_api_settings_post(self, request):
|
async def handle_api_settings_post(self, request):
|
||||||
@@ -4232,7 +4397,7 @@ class KobraXBridge:
|
|||||||
cfg.read(config_path, encoding="utf-8")
|
cfg.read(config_path, encoding="utf-8")
|
||||||
|
|
||||||
# Sections sicherstellen
|
# Sections sicherstellen
|
||||||
for section in ("connection", "print", "bridge", "ace_dry_presets"):
|
for section in ("connection", "print", "bridge", "ace_dry_presets", "spoolman"):
|
||||||
if not cfg.has_section(section):
|
if not cfg.has_section(section):
|
||||||
cfg.add_section(section)
|
cfg.add_section(section)
|
||||||
|
|
||||||
@@ -4262,6 +4427,16 @@ class KobraXBridge:
|
|||||||
elif cfg.has_option("bridge", "printer_name"):
|
elif cfg.has_option("bridge", "printer_name"):
|
||||||
cfg.remove_option("bridge", "printer_name")
|
cfg.remove_option("bridge", "printer_name")
|
||||||
|
|
||||||
|
# Spoolman
|
||||||
|
if "spoolman_server" in data:
|
||||||
|
cfg.set("spoolman", "server", str(data["spoolman_server"]).strip())
|
||||||
|
if "spoolman_sync_rate" in data:
|
||||||
|
try:
|
||||||
|
sr = max(0, int(data["spoolman_sync_rate"]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sr = 30
|
||||||
|
cfg.set("spoolman", "sync_rate", str(sr))
|
||||||
|
|
||||||
incoming_presets = data.get("ace_dry_presets") if isinstance(data, dict) else None
|
incoming_presets = data.get("ace_dry_presets") if isinstance(data, dict) else None
|
||||||
presets = self._sanitize_ace_dry_presets(incoming_presets if isinstance(incoming_presets, dict) else self._ace_dry_presets)
|
presets = self._sanitize_ace_dry_presets(incoming_presets if isinstance(incoming_presets, dict) else self._ace_dry_presets)
|
||||||
for key, val in presets.items():
|
for key, val in presets.items():
|
||||||
@@ -4421,7 +4596,8 @@ class KobraXBridge:
|
|||||||
for _k in ("PRINTER_IP", "MQTT_PORT", "MQTT_USERNAME", "MQTT_PASSWORD",
|
for _k in ("PRINTER_IP", "MQTT_PORT", "MQTT_USERNAME", "MQTT_PASSWORD",
|
||||||
"MODE_ID", "DEVICE_ID", "DEFAULT_AMS_SLOT", "AUTO_LEVELING",
|
"MODE_ID", "DEVICE_ID", "DEFAULT_AMS_SLOT", "AUTO_LEVELING",
|
||||||
"CAMERA_ON_PRINT", "WEB_UPLOAD_WARNING", "PRINT_START_DIALOG",
|
"CAMERA_ON_PRINT", "WEB_UPLOAD_WARNING", "PRINT_START_DIALOG",
|
||||||
"FILE_READY_DIALOG", "BRIDGE_PRINTER_NAME"):
|
"FILE_READY_DIALOG", "BRIDGE_PRINTER_NAME",
|
||||||
|
"SPOOLMAN_SERVER", "SPOOLMAN_SYNC_RATE"):
|
||||||
os.environ.pop(_k, None)
|
os.environ.pop(_k, None)
|
||||||
|
|
||||||
in_docker = os.path.exists("/.dockerenv") or os.environ.get("KX_IN_DOCKER")
|
in_docker = os.path.exists("/.dockerenv") or os.environ.get("KX_IN_DOCKER")
|
||||||
@@ -4457,9 +4633,10 @@ class KobraXBridge:
|
|||||||
|
|
||||||
# ─── Update ──────────────────────────────────────────────────────────────
|
# ─── Update ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=1"
|
STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=1"
|
||||||
DEV_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=10&pre-release=true"
|
NIGHTLY_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=5&pre-release=true"
|
||||||
GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag"
|
DEV_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=10&pre-release=true"
|
||||||
|
GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag"
|
||||||
|
|
||||||
def _read_version(self) -> str:
|
def _read_version(self) -> str:
|
||||||
# PyInstaller-Onefile entpackt VERSION (per kx-bridge.spec datas) nach
|
# PyInstaller-Onefile entpackt VERSION (per kx-bridge.spec datas) nach
|
||||||
@@ -4534,8 +4711,14 @@ class KobraXBridge:
|
|||||||
|
|
||||||
async def handle_api_update_check(self, request):
|
async def handle_api_update_check(self, request):
|
||||||
current = self._read_version()
|
current = self._read_version()
|
||||||
|
is_nightly = "nightly" in current
|
||||||
is_dev = "-dev+" in current
|
is_dev = "-dev+" in current
|
||||||
api_url = self.DEV_RELEASE_API if is_dev else self.STABLE_RELEASE_API
|
if is_nightly:
|
||||||
|
api_url = self.NIGHTLY_RELEASE_API
|
||||||
|
elif is_dev:
|
||||||
|
api_url = self.DEV_RELEASE_API
|
||||||
|
else:
|
||||||
|
api_url = self.STABLE_RELEASE_API
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||||
@@ -4544,14 +4727,37 @@ class KobraXBridge:
|
|||||||
releases = await resp.json(content_type=None)
|
releases = await resp.json(content_type=None)
|
||||||
if not releases:
|
if not releases:
|
||||||
return web.json_response({"error": "Keine Releases gefunden"}, status=404)
|
return web.json_response({"error": "Keine Releases gefunden"}, status=404)
|
||||||
# Dev: neuestes Release mit "-dev+" im Tag suchen
|
|
||||||
if is_dev:
|
if is_nightly:
|
||||||
|
# Neuestes Prerelease mit nightly-Tag suchen
|
||||||
|
nightly_releases = [r for r in releases if r.get("prerelease") and "nightly" in r.get("tag_name", "")]
|
||||||
|
if not nightly_releases:
|
||||||
|
return web.json_response({"error": "Keine Nightly-Releases gefunden"}, status=404)
|
||||||
|
data = nightly_releases[0]
|
||||||
|
tag = data.get("tag_name", "")
|
||||||
|
# Tag-Format: "nightly-0.9.27-nightly4", current: "0.9.27-nightly4"
|
||||||
|
tag_version = tag[len("nightly-"):] if tag.startswith("nightly-") else tag
|
||||||
|
update_available = tag_version != current
|
||||||
|
latest = tag
|
||||||
|
return web.json_response({
|
||||||
|
"current": current,
|
||||||
|
"latest": latest,
|
||||||
|
"update_available": update_available,
|
||||||
|
"tag": tag,
|
||||||
|
"docker_only": True,
|
||||||
|
"changelog": data.get("body", ""),
|
||||||
|
})
|
||||||
|
elif is_dev:
|
||||||
dev_releases = [r for r in releases if "-dev+" in r.get("tag_name", "")]
|
dev_releases = [r for r in releases if "-dev+" in r.get("tag_name", "")]
|
||||||
if not dev_releases:
|
if not dev_releases:
|
||||||
return web.json_response({"error": "Keine Dev-Releases gefunden"}, status=404)
|
return web.json_response({"error": "Keine Dev-Releases gefunden"}, status=404)
|
||||||
data = dev_releases[0]
|
data = dev_releases[0]
|
||||||
else:
|
else:
|
||||||
data = releases[0]
|
# Stable: nur non-prerelease nehmen
|
||||||
|
stable_releases = [r for r in releases if not r.get("prerelease")]
|
||||||
|
if not stable_releases:
|
||||||
|
return web.json_response({"error": "Keine Stable-Releases gefunden"}, status=404)
|
||||||
|
data = stable_releases[0]
|
||||||
tag = data.get("tag_name", "")
|
tag = data.get("tag_name", "")
|
||||||
latest = tag.lstrip("v")
|
latest = tag.lstrip("v")
|
||||||
if is_dev:
|
if is_dev:
|
||||||
@@ -4565,6 +4771,7 @@ class KobraXBridge:
|
|||||||
"update_available": update_available,
|
"update_available": update_available,
|
||||||
"tag": tag,
|
"tag": tag,
|
||||||
"download_url": download_url,
|
"download_url": download_url,
|
||||||
|
"docker_only": False,
|
||||||
"changelog": data.get("body", ""),
|
"changelog": data.get("body", ""),
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -4585,6 +4792,10 @@ class KobraXBridge:
|
|||||||
async def handle_api_update_apply(self, request):
|
async def handle_api_update_apply(self, request):
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
new_tag = data.get("tag", "")
|
new_tag = data.get("tag", "")
|
||||||
|
if "nightly" in self._read_version():
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Nightly-Updates laufen über Docker: "
|
||||||
|
"docker compose pull && docker compose up -d"}, status=400)
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Self-Update wird im Binary-Modus nicht unterstützt – "
|
{"error": "Self-Update wird im Binary-Modus nicht unterstützt – "
|
||||||
@@ -5120,6 +5331,7 @@ def build_app(bridge: KobraXBridge) -> web.Application:
|
|||||||
r.add_get("/api/camera/snapshot", bridge.handle_api_camera_snapshot)
|
r.add_get("/api/camera/snapshot", bridge.handle_api_camera_snapshot)
|
||||||
r.add_post("/api/camera/start", bridge.handle_api_camera_start)
|
r.add_post("/api/camera/start", bridge.handle_api_camera_start)
|
||||||
r.add_post("/api/camera/stop", bridge.handle_api_camera_stop)
|
r.add_post("/api/camera/stop", bridge.handle_api_camera_stop)
|
||||||
|
r.add_post("/api/camera/reset", bridge.handle_api_camera_reset)
|
||||||
r.add_get("/api/state", bridge.handle_api_state)
|
r.add_get("/api/state", bridge.handle_api_state)
|
||||||
r.add_get("/api/settings", bridge.handle_api_settings_get)
|
r.add_get("/api/settings", bridge.handle_api_settings_get)
|
||||||
r.add_post("/api/settings", bridge.handle_api_settings_post)
|
r.add_post("/api/settings", bridge.handle_api_settings_post)
|
||||||
|
|||||||
18
moonraker-obico.cfg.example
Normal file
18
moonraker-obico.cfg.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[server]
|
||||||
|
url = http://127.0.0.1:3334
|
||||||
|
auth_token = REPLACE_ME
|
||||||
|
sentry_opt = out
|
||||||
|
|
||||||
|
[moonraker]
|
||||||
|
host = 127.0.0.1
|
||||||
|
port = 7125
|
||||||
|
|
||||||
|
[webcam]
|
||||||
|
disable_video_streaming = False
|
||||||
|
snapshot_url = http://127.0.0.1:7125/api/camera/snapshot
|
||||||
|
stream_url = http://127.0.0.1:7125/api/camera/stream
|
||||||
|
target_fps = 5
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
path = /opt/printer_data/logs/moonraker-obico.log
|
||||||
|
level = INFO
|
||||||
31
package-lock.json
generated
31
package-lock.json
generated
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "kx-bridge",
|
|
||||||
"version": "0.9.27",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "kx-bridge",
|
|
||||||
"version": "0.9.27",
|
|
||||||
"devDependencies": {
|
|
||||||
"prettier": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prettier": {
|
|
||||||
"version": "3.8.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
|
|
||||||
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"prettier": "bin/prettier.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
23
package.json
23
package.json
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "kx-bridge",
|
|
||||||
"version": "0.9.27",
|
|
||||||
"description": "Moonraker-compatible bridge for Anycubic Kobra X",
|
|
||||||
"type": "module",
|
|
||||||
"devDependencies": {
|
|
||||||
"prettier": "^3.0.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"format:js": "prettier --write 'web/**/*.js' '*.js'",
|
|
||||||
"format:json": "prettier --write '*.json' 'web/**/*.json'",
|
|
||||||
"format:web": "prettier --write 'web/**/*.{js,json,html,css,yml,yaml,md}'",
|
|
||||||
"format": "npm run format:web"
|
|
||||||
},
|
|
||||||
"prettier": {
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": false,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"printWidth": 100,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
pull_request_template.md
Normal file
21
pull_request_template.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
## Description
|
||||||
|
<!-- What does this PR change? -->
|
||||||
|
|
||||||
|
## Related Issue
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Type
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] Feature
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Refactoring
|
||||||
|
|
||||||
|
## Tested with
|
||||||
|
- OrcaSlicer Version:
|
||||||
|
- Printer:
|
||||||
|
- Moonraker/Klipper Version:
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Tests added/updated
|
||||||
|
- [ ] CHANGELOG.md updated
|
||||||
|
- [ ] No debug code included
|
||||||
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
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Code formatting
|
|
||||||
black>=24.1.0
|
|
||||||
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"] == ""
|
||||||
67
tests/test_filament_profiles_per_printer.py
Normal file
67
tests/test_filament_profiles_per_printer.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Per-printer filament-profile isolation (config_loader).
|
||||||
|
|
||||||
|
Regression test for the multi-printer bug (issue #74): the slot->profile mapping
|
||||||
|
and ``visible_vendors`` lived in a single global ``[filament_profiles]`` section,
|
||||||
|
so configuring one printer overwrote the other and after a restart both loaded
|
||||||
|
the same map. Each printer now uses its own ``[filament_profiles_<id>]`` section,
|
||||||
|
with a read-fallback to the legacy global section for backward compatibility.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) # repo root
|
||||||
|
import config_loader # noqa: E402
|
||||||
|
|
||||||
|
BASE_INI = (
|
||||||
|
"[printer_1]\nname = K1\n\n"
|
||||||
|
"[printer_2]\nname = K2\n\n"
|
||||||
|
"[filament_profiles]\n"
|
||||||
|
"visible_vendors = Anycubic, SUNLU\n"
|
||||||
|
"slot_0_vendor = Anycubic\nslot_0_name = Anycubic PLA+\nslot_0_id = GFPLA+\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _use_ini(monkeypatch, tmp_path, text=BASE_INI):
|
||||||
|
path = tmp_path / "config.ini"
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
monkeypatch.setattr(config_loader, "_find_config_file", lambda: path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_global_still_works(tmp_path, monkeypatch):
|
||||||
|
"""No printer_id -> original global section (single-printer back-compat)."""
|
||||||
|
_use_ini(monkeypatch, tmp_path)
|
||||||
|
assert config_loader.list_filament_profiles()[0]["name"] == "Anycubic PLA+"
|
||||||
|
assert config_loader.list_visible_vendors() == ["Anycubic", "SUNLU"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_falls_back_to_global_until_first_save(tmp_path, monkeypatch):
|
||||||
|
"""Before any per-printer save, both printers see the global mapping."""
|
||||||
|
_use_ini(monkeypatch, tmp_path)
|
||||||
|
assert config_loader.list_filament_profiles("1")[0]["name"] == "Anycubic PLA+"
|
||||||
|
assert config_loader.list_filament_profiles("2")[0]["name"] == "Anycubic PLA+"
|
||||||
|
|
||||||
|
|
||||||
|
def test_saving_one_printer_does_not_touch_the_other(tmp_path, monkeypatch):
|
||||||
|
"""Core regression: configuring printer 1 must not change printer 2."""
|
||||||
|
_use_ini(monkeypatch, tmp_path)
|
||||||
|
config_loader.save_filament_profiles(
|
||||||
|
{0: {"vendor": "KINGROON", "name": "KINGROON PLA Basic", "id": "Pc0b8a01"}}, "1")
|
||||||
|
assert config_loader.list_filament_profiles("1")[0]["name"] == "KINGROON PLA Basic"
|
||||||
|
assert config_loader.list_filament_profiles("2")[0]["name"] == "Anycubic PLA+"
|
||||||
|
# legacy global section preserved untouched
|
||||||
|
assert config_loader.list_filament_profiles()[0]["name"] == "Anycubic PLA+"
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_vendors_isolated_per_printer(tmp_path, monkeypatch):
|
||||||
|
_use_ini(monkeypatch, tmp_path)
|
||||||
|
config_loader.save_visible_vendors(["KINGROON"], "1")
|
||||||
|
assert config_loader.list_visible_vendors("1") == ["KINGROON"]
|
||||||
|
assert config_loader.list_visible_vendors("2") == ["Anycubic", "SUNLU"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_visible_vendors_keeps_slot_fallback(tmp_path, monkeypatch):
|
||||||
|
"""Creating a per-printer section only for vendors must not orphan slots."""
|
||||||
|
_use_ini(monkeypatch, tmp_path)
|
||||||
|
config_loader.save_visible_vendors(["KINGROON"], "1")
|
||||||
|
assert config_loader.list_filament_profiles("1")[0]["name"] == "Anycubic PLA+"
|
||||||
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
|
||||||
@@ -53,8 +53,26 @@ function _loadSpoolmanStatus(){
|
|||||||
fetch(_apiUrl('/kx/spoolman/status')).then(function(r){return r.json();}).then(function(d){
|
fetch(_apiUrl('/kx/spoolman/status')).then(function(r){return r.json();}).then(function(d){
|
||||||
_spoolmanStatus=d;
|
_spoolmanStatus=d;
|
||||||
_slotSpoolMap=d.slot_spools||{};
|
_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(){});
|
}).catch(function(){});
|
||||||
}
|
}
|
||||||
|
function _updateSpoolmanStatusDot(){
|
||||||
|
var dot=document.getElementById('spoolman-status-dot');
|
||||||
|
var lbl=document.getElementById('spoolman-status-lbl');
|
||||||
|
if(!dot||!lbl)return;
|
||||||
|
if(_spoolmanStatus.configured){
|
||||||
|
dot.style.color='var(--ok)';lbl.textContent=_spoolmanStatus.server||'verbunden';
|
||||||
|
} else {
|
||||||
|
dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _buildSpoolmanSection(){
|
function _buildSpoolmanSection(){
|
||||||
var sec=document.getElementById('fd-spoolman-section');
|
var sec=document.getElementById('fd-spoolman-section');
|
||||||
@@ -67,12 +85,8 @@ function _buildSpoolmanSection(){
|
|||||||
if(loading)loading.style.display='';
|
if(loading)loading.style.display='';
|
||||||
|
|
||||||
var usedSlots={};
|
var usedSlots={};
|
||||||
document.querySelectorAll('#fd-slots select').forEach(function(sel){
|
(_amsSlots||[]).forEach(function(slot){
|
||||||
var idx=parseInt(sel.value);
|
usedSlots[slot.slot_index]=slot;
|
||||||
if(idx>=0){
|
|
||||||
var slot=(_amsSlots||[]).find(function(s){return s.slot_index===idx;});
|
|
||||||
if(slot&&!usedSlots[idx])usedSlots[idx]=slot;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(d){
|
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(d){
|
||||||
@@ -278,7 +292,7 @@ function renderPrinterDropdown(){
|
|||||||
menu.innerHTML=_printers.map(function(p){
|
menu.innerHTML=_printers.map(function(p){
|
||||||
var active=_activePrinter&&String(p.id)===String(_activePrinter.id);
|
var active=_activePrinter&&String(p.id)===String(_activePrinter.id);
|
||||||
var num=p.id;
|
var num=p.id;
|
||||||
return '<a href="/printer'+num+'" style="display:block;padding:10px 14px;color:'+(active?'var(--accent)':'var(--txt)')+';text-decoration:none;font-size:13px;border-bottom:1px solid var(--border)" '+(active?'style="font-weight:600"':'')+'>'+
|
return '<a href="'+((p.bridge_url||'').replace(/\/+$/,''))+'/printer'+num+'" style="display:block;padding:10px 14px;color:'+(active?'var(--accent)':'var(--txt)')+';text-decoration:none;font-size:13px;border-bottom:1px solid var(--border)" '+(active?'style="font-weight:600"':'')+'>'+
|
||||||
(active?'▶ ':'')+p.name+'</a>';
|
(active?'▶ ':'')+p.name+'</a>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -365,12 +379,10 @@ function applyLang(){
|
|||||||
setText('d-chart-label',T.panel_temps_chart);
|
setText('d-chart-label',T.panel_temps_chart);
|
||||||
// Axis labels
|
// Axis labels
|
||||||
setText('ptitle-motion-xy',T.panel_motion_xy);
|
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-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-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-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-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(':','')));
|
document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':','')));
|
||||||
// Console
|
// Console
|
||||||
setText('ptitle-console',T.panel_console_title);
|
setText('ptitle-console',T.panel_console_title);
|
||||||
@@ -385,6 +397,11 @@ function applyLang(){
|
|||||||
setText('setcat-lbl-display',T.settings_cat_display||'Darstellung');
|
setText('setcat-lbl-display',T.settings_cat_display||'Darstellung');
|
||||||
setText('setcat-lbl-display2',T.settings_cat_display||'Darstellung');
|
setText('setcat-lbl-display2',T.settings_cat_display||'Darstellung');
|
||||||
setText('setcat-lbl-filament',T.settings_cat_filament||'Filament');
|
setText('setcat-lbl-filament',T.settings_cat_filament||'Filament');
|
||||||
|
setText('setcat-lbl-integrations',T.settings_integrations||'Integrationen');
|
||||||
|
setText('modal-sec-spoolman',T.modal_sec_spoolman||'Spoolman');
|
||||||
|
setText('lbl-spoolman-url',T.lbl_spoolman_url||'Server-URL');
|
||||||
|
setText('lbl-spoolman-sync-rate',T.lbl_spoolman_sync_rate||'Sync-Rate (s, 0=aus)');
|
||||||
|
setText('modal-sec-obico',T.modal_sec_obico||'Obico');
|
||||||
setText('setcat-lbl-system',T.settings_version||'System');
|
setText('setcat-lbl-system',T.settings_version||'System');
|
||||||
setText('lbl-set-lang',T.settings_cat_language||'Sprache');
|
setText('lbl-set-lang',T.settings_cat_language||'Sprache');
|
||||||
setText('lbl-set-theme',T.settings_cat_theme||'Hell / Dunkel umschalten');
|
setText('lbl-set-theme',T.settings_cat_theme||'Hell / Dunkel umschalten');
|
||||||
@@ -534,6 +551,7 @@ function ensureAceDryCards(){
|
|||||||
// defer until DOM ready
|
// defer until DOM ready
|
||||||
window.addEventListener('DOMContentLoaded',function(){
|
window.addEventListener('DOMContentLoaded',function(){
|
||||||
setLanguage(currentLang).catch(function(){});
|
setLanguage(currentLang).catch(function(){});
|
||||||
|
_loadSpoolmanStatus();
|
||||||
// Kein Drucker konfiguriert? → direkt in den Drucker-Tab (zeigt "+ Drucker hinzufügen")
|
// Kein Drucker konfiguriert? → direkt in den Drucker-Tab (zeigt "+ Drucker hinzufügen")
|
||||||
fetch('/kx/printers').then(function(r){return r.json()}).then(function(d){
|
fetch('/kx/printers').then(function(r){return r.json()}).then(function(d){
|
||||||
if(!d.result||!d.result.length){showPanel('printers');loadPrinterTab();}
|
if(!d.result||!d.result.length){showPanel('printers');loadPrinterTab();}
|
||||||
@@ -732,7 +750,7 @@ function applyState(){
|
|||||||
frb.style.display='none';
|
frb.style.display='none';
|
||||||
if(!_fdDialogOpen&&!_fdUserCancelled&&_fdAutoOpenedFile!==s.file_ready){
|
if(!_fdDialogOpen&&!_fdUserCancelled&&_fdAutoOpenedFile!==s.file_ready){
|
||||||
_fdAutoOpenedFile=s.file_ready;
|
_fdAutoOpenedFile=s.file_ready;
|
||||||
startReadyFileWithSlots(s.file_ready,true);
|
startReadyFileWithSlots(s.file_ready,true,s.filament_mismatch||null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
frb.style.display='flex';
|
frb.style.display='flex';
|
||||||
@@ -945,6 +963,22 @@ function applyState(){
|
|||||||
var tt=(profile.name||'')+(profile.id?' ('+profile.id+')':'');
|
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>';
|
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':'')
|
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+')">'
|
+'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
|
||||||
+'<div class="slot-circle" style="background:'+col+'"></div>'
|
+'<div class="slot-circle" style="background:'+col+'"></div>'
|
||||||
@@ -952,6 +986,7 @@ function applyState(){
|
|||||||
+vendorBadge
|
+vendorBadge
|
||||||
+'<div class="slot-label">'+slotLabel+'</div>'
|
+'<div class="slot-label">'+slotLabel+'</div>'
|
||||||
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</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 style="font-size:9px;color:var(--txt2);margin-top:2px">✏</div>'
|
||||||
+'</div>';
|
+'</div>';
|
||||||
});
|
});
|
||||||
@@ -962,7 +997,10 @@ function applyState(){
|
|||||||
}
|
}
|
||||||
html+='</div></div>';
|
html+='</div></div>';
|
||||||
});
|
});
|
||||||
document.getElementById('ams-slots').innerHTML=html;
|
// Nicht rendern wenn ein Spool-Dropdown gerade offen ist (verhindert Schließen beim Poll)
|
||||||
|
var activeEl=document.activeElement;
|
||||||
|
var spoolOpen=activeEl&&activeEl.tagName==='SELECT'&&activeEl.dataset.spoolSlot!=null;
|
||||||
|
if(!spoolOpen) document.getElementById('ams-slots').innerHTML=html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// camera overlay
|
// camera overlay
|
||||||
@@ -1058,6 +1096,11 @@ function openSettings(){
|
|||||||
pi.value=sec;
|
pi.value=sec;
|
||||||
}
|
}
|
||||||
renderFilamentMapping(d.filament_profiles||{});
|
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);
|
||||||
|
_updateSpoolmanStatusDot();
|
||||||
});
|
});
|
||||||
// Sprach-Select im Settings-Panel mit aktueller Sprache spiegeln
|
// Sprach-Select im Settings-Panel mit aktueller Sprache spiegeln
|
||||||
var ls=document.getElementById('s-lang-select');
|
var ls=document.getElementById('s-lang-select');
|
||||||
@@ -1201,6 +1244,64 @@ function _vendorCheck(cb){
|
|||||||
var v=cb.getAttribute('data-vendor');
|
var v=cb.getAttribute('data-vendor');
|
||||||
if(cb.checked)_vendorChecklistSel[v]=true; else delete _vendorChecklistSel[v];
|
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(){
|
function saveVisibleVendors(){
|
||||||
var vendors=Object.keys(_vendorChecklistSel);
|
var vendors=Object.keys(_vendorChecklistSel);
|
||||||
fetch(_apiUrl('/kx/filament/visible_vendors'),{method:'POST',headers:{'Content-Type':'application/json'},
|
fetch(_apiUrl('/kx/filament/visible_vendors'),{method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
@@ -1398,6 +1499,110 @@ function _fillSlotProfileDropdown(material, currentVendor, currentName){
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// ── Pickr color picker ──────────────────────────────────────────────────────
|
||||||
|
var _pickr=null;
|
||||||
|
|
||||||
|
function _initPickr(hex){
|
||||||
|
// destroy previous instance if exists
|
||||||
|
if(_pickr){ try{ _pickr.destroyAndRemove(); }catch(e){} _pickr=null; }
|
||||||
|
var anchor=document.getElementById('slot-pickr-anchor');
|
||||||
|
if(!anchor||typeof Pickr==='undefined') return;
|
||||||
|
// fresh button element so Pickr can mount
|
||||||
|
anchor.innerHTML='<div id="slot-pickr-btn"></div>';
|
||||||
|
_pickr=Pickr.create({
|
||||||
|
el:'#slot-pickr-btn',
|
||||||
|
theme:'nano',
|
||||||
|
default: hex||'#808080',
|
||||||
|
inline: true,
|
||||||
|
showAlways: true,
|
||||||
|
components:{
|
||||||
|
preview:true, opacity:false, hue:true,
|
||||||
|
interaction:{ hex:true, rgba:false, input:true, save:false, clear:false }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_pickr.on('change',function(color){
|
||||||
|
var h=color.toHEXA().toString().slice(0,7);
|
||||||
|
document.getElementById('slot-edit-color').value=h;
|
||||||
|
document.getElementById('slot-edit-preview').style.background=h;
|
||||||
|
});
|
||||||
|
// Theme anpassen: Pickr benutzt eigene CSS-Variablen, wir überschreiben via style
|
||||||
|
requestAnimationFrame(function(){
|
||||||
|
var el=anchor.querySelector('.pickr');
|
||||||
|
if(el) el.style.cssText='width:100%';
|
||||||
|
var app=anchor.querySelector('.pcr-app');
|
||||||
|
if(app){
|
||||||
|
app.style.cssText='position:relative;width:100%;box-shadow:none;background:transparent';
|
||||||
|
var btn=app.querySelector('.pcr-result');
|
||||||
|
if(btn) btn.style.cssText='background:var(--raised);border:1px solid var(--border);color:var(--txt);border-radius:6px;font-size:12px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Color swatches (localStorage, max 16) ──────────────────────────────────
|
||||||
|
var _SWATCH_KEY='kxb_color_swatches';
|
||||||
|
var _SWATCH_MAX=16;
|
||||||
|
|
||||||
|
function _loadSwatches(){
|
||||||
|
try{ return JSON.parse(localStorage.getItem(_SWATCH_KEY)||'[]'); }catch(e){ return []; }
|
||||||
|
}
|
||||||
|
function _saveSwatches(arr){ try{ localStorage.setItem(_SWATCH_KEY, JSON.stringify(arr)); }catch(e){} }
|
||||||
|
|
||||||
|
function _addSwatch(hex){
|
||||||
|
var arr=_loadSwatches().filter(function(c){ return c.toLowerCase()!==hex.toLowerCase(); });
|
||||||
|
arr.unshift(hex);
|
||||||
|
if(arr.length>_SWATCH_MAX) arr=arr.slice(0,_SWATCH_MAX);
|
||||||
|
_saveSwatches(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderSwatches(){
|
||||||
|
var el=document.getElementById('slot-color-swatches');
|
||||||
|
if(!el) return;
|
||||||
|
var arr=_loadSwatches();
|
||||||
|
if(!arr.length){ el.style.display='none'; return; }
|
||||||
|
el.style.display='flex';
|
||||||
|
el.innerHTML=arr.map(function(c){
|
||||||
|
return '<div title="'+c+'" onclick="slotPickSwatch(\''+c+'\')" style="width:22px;height:22px;border-radius:4px;background:'+c+
|
||||||
|
';border:2px solid rgba(255,255,255,.2);cursor:pointer;flex-shrink:0"></div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotPickSwatch(hex){
|
||||||
|
if(_pickr){ _pickr.setColor(hex); }
|
||||||
|
var ci=document.getElementById('slot-edit-color');
|
||||||
|
if(ci) ci.value=hex;
|
||||||
|
document.getElementById('slot-edit-preview').style.background=hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Copy color from other slot ──────────────────────────────────────────────
|
||||||
|
function _renderCopyFromSlot(currentGlobalIdx){
|
||||||
|
var slots=(window._amsSlots||[]).filter(function(s){
|
||||||
|
return s.global_index!==currentGlobalIdx && s.status==5 && Array.isArray(s.color);
|
||||||
|
});
|
||||||
|
var row=document.getElementById('slot-copy-row');
|
||||||
|
var sel=document.getElementById('slot-copy-select');
|
||||||
|
if(!row||!sel) return;
|
||||||
|
if(!slots.length){ row.style.display='none'; return; }
|
||||||
|
row.style.display='';
|
||||||
|
var ph=document.getElementById('lbl-slot-copy-from');
|
||||||
|
var phTxt=ph?ph.textContent:(T.slot_copy_from||'Copy color from slot…');
|
||||||
|
sel.innerHTML='<option value="">'+phTxt+'</option>'+slots.map(function(s){
|
||||||
|
var rgb=s.color;
|
||||||
|
var hex='#'+rgb.map(function(v){return('0'+Math.min(255,v).toString(16)).slice(-2)}).join('');
|
||||||
|
return '<option value="'+hex+'">Slot '+(s.global_index+1)+' — '+(s.type||'?')+' '+hex+'</option>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotCopyColor(sel){
|
||||||
|
if(!sel.value) return;
|
||||||
|
var ci=document.getElementById('slot-edit-color');
|
||||||
|
if(!ci) return;
|
||||||
|
ci.value=sel.value;
|
||||||
|
document.getElementById('slot-edit-preview').style.background=sel.value;
|
||||||
|
sel.selectedIndex=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function openSlotEdit(i){
|
function openSlotEdit(i){
|
||||||
var slot=(window._amsSlots||[])[i]||{};
|
var slot=(window._amsSlots||[])[i]||{};
|
||||||
var globalIdx=slot.global_index!=null?slot.global_index:(slot.index!=null?slot.index:i);
|
var globalIdx=slot.global_index!=null?slot.global_index:(slot.index!=null?slot.index:i);
|
||||||
@@ -1409,6 +1614,9 @@ function openSlotEdit(i){
|
|||||||
var ci=document.getElementById('slot-edit-color');
|
var ci=document.getElementById('slot-edit-color');
|
||||||
ci.value=hex;
|
ci.value=hex;
|
||||||
document.getElementById('slot-edit-preview').style.background=hex;
|
document.getElementById('slot-edit-preview').style.background=hex;
|
||||||
|
_initPickr(hex);
|
||||||
|
_renderSwatches();
|
||||||
|
_renderCopyFromSlot(globalIdx);
|
||||||
var mat=(slot.type||'PLA').toUpperCase();
|
var mat=(slot.type||'PLA').toUpperCase();
|
||||||
document.getElementById('slot-edit-mat').value=mat;
|
document.getElementById('slot-edit-mat').value=mat;
|
||||||
var btns=document.getElementById('slot-mat-btns');
|
var btns=document.getElementById('slot-mat-btns');
|
||||||
@@ -1533,6 +1741,7 @@ function hexToRgb(hex){
|
|||||||
}
|
}
|
||||||
function saveSlotEdit(){
|
function saveSlotEdit(){
|
||||||
var hex=document.getElementById('slot-edit-color').value;
|
var hex=document.getElementById('slot-edit-color').value;
|
||||||
|
_addSwatch(hex);
|
||||||
var mat=document.getElementById('slot-edit-mat').value.trim().toUpperCase()||'PLA';
|
var mat=document.getElementById('slot-edit-mat').value.trim().toUpperCase()||'PLA';
|
||||||
var color=hexToRgb(hex);
|
var color=hexToRgb(hex);
|
||||||
var slotIdx=_slotEditIndex;
|
var slotIdx=_slotEditIndex;
|
||||||
@@ -1626,12 +1835,15 @@ function saveSettings(){
|
|||||||
print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10),
|
print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10),
|
||||||
web_upload_warning:webUploadWarning,
|
web_upload_warning:webUploadWarning,
|
||||||
poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)),
|
poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)),
|
||||||
|
spoolman_server: (document.getElementById('s-spoolman-url')||{}).value||'',
|
||||||
|
spoolman_sync_rate: Math.max(0,parseInt((document.getElementById('s-spoolman-sync-rate')||{}).value||'30',10)),
|
||||||
}).then(function(){
|
}).then(function(){
|
||||||
btn.textContent=T.update_restarting;
|
btn.textContent=T.update_restarting;
|
||||||
setTimeout(function(){
|
setTimeout(function(){
|
||||||
btn.disabled=false;
|
btn.disabled=false;
|
||||||
setText('btn-save-settings',T.settings_save);
|
setText('btn-save-settings',T.settings_save);
|
||||||
closeSettings();
|
closeSettings();
|
||||||
|
_loadSpoolmanStatus();
|
||||||
poll();
|
poll();
|
||||||
},4000);
|
},4000);
|
||||||
}).catch(function(e){
|
}).catch(function(e){
|
||||||
@@ -1639,21 +1851,33 @@ function saveSettings(){
|
|||||||
clog('Settings-Fehler: '+e,'msg-err');
|
clog('Settings-Fehler: '+e,'msg-err');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
var _updateDockerOnly=false;
|
||||||
function checkUpdate(){
|
function checkUpdate(){
|
||||||
var sb=document.getElementById('update-status');
|
var sb=document.getElementById('update-status');
|
||||||
sb.textContent=T.update_checking;
|
sb.textContent=T.update_checking;
|
||||||
document.getElementById('btn-update-apply').style.display='none';
|
document.getElementById('btn-update-apply').style.display='none';
|
||||||
_updateTag='';_updateUrl='';
|
_updateTag='';_updateUrl='';_updateDockerOnly=false;
|
||||||
fetch(_apiUrl('/api/update/check')).then(function(r){return r.json()}).then(function(d){
|
fetch(_apiUrl('/api/update/check')).then(function(r){return r.json()}).then(function(d){
|
||||||
if(d.error){sb.textContent=T.update_error+': '+d.error;return;}
|
if(d.error){sb.textContent=T.update_error+': '+d.error;return;}
|
||||||
var cl=document.getElementById('update-changelog');
|
var cl=document.getElementById('update-changelog');
|
||||||
if(d.changelog&&d.changelog.trim()){cl.textContent=d.changelog;cl.style.display='block';}
|
if(d.changelog&&d.changelog.trim()){
|
||||||
else{cl.style.display='none';}
|
// Changelog als Markdown-Text (pre-formatiert) anzeigen
|
||||||
|
cl.textContent=d.changelog;cl.style.display='block';
|
||||||
|
} else {cl.style.display='none';}
|
||||||
|
_updateDockerOnly=!!d.docker_only;
|
||||||
if(d.update_available){
|
if(d.update_available){
|
||||||
sb.textContent='v'+d.latest+' '+T.update_available;
|
sb.textContent=d.latest+' '+T.update_available;
|
||||||
sb.style.color='var(--ok)';
|
sb.style.color='var(--ok)';
|
||||||
_updateTag=d.tag;_updateUrl=d.download_url;
|
_updateTag=d.tag;_updateUrl=d.download_url||'';
|
||||||
document.getElementById('btn-update-apply').style.display='inline-block';
|
var btn=document.getElementById('btn-update-apply');
|
||||||
|
if(_updateDockerOnly){
|
||||||
|
btn.textContent=T.update_docker||'docker compose pull';
|
||||||
|
btn.title='docker compose pull && docker compose up -d';
|
||||||
|
} else {
|
||||||
|
btn.textContent=T.update_apply;
|
||||||
|
btn.title='';
|
||||||
|
}
|
||||||
|
btn.style.display='inline-block';
|
||||||
} else {
|
} else {
|
||||||
sb.textContent=T.update_none;
|
sb.textContent=T.update_none;
|
||||||
sb.style.color='var(--txt2)';
|
sb.style.color='var(--txt2)';
|
||||||
@@ -1661,9 +1885,21 @@ function checkUpdate(){
|
|||||||
}).catch(function(e){sb.textContent=T.update_error+': '+e;});
|
}).catch(function(e){sb.textContent=T.update_error+': '+e;});
|
||||||
}
|
}
|
||||||
function applyUpdate(){
|
function applyUpdate(){
|
||||||
if(!_updateUrl)return;
|
|
||||||
var sb=document.getElementById('update-status');
|
var sb=document.getElementById('update-status');
|
||||||
var btn=document.getElementById('btn-update-apply');
|
var btn=document.getElementById('btn-update-apply');
|
||||||
|
if(_updateDockerOnly){
|
||||||
|
// Nightly: kein Self-Update, Docker-Befehl in Zwischenablage kopieren
|
||||||
|
var cmd='docker compose pull && docker compose up -d';
|
||||||
|
if(navigator.clipboard){
|
||||||
|
navigator.clipboard.writeText(cmd).then(function(){
|
||||||
|
sb.textContent=T.update_docker_copied||'Befehl kopiert: '+cmd;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sb.textContent=cmd;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!_updateUrl)return;
|
||||||
btn.disabled=true;sb.textContent=T.update_applying;
|
btn.disabled=true;sb.textContent=T.update_applying;
|
||||||
post('/api/update/apply',{download_url:_updateUrl,tag:_updateTag}).then(function(){
|
post('/api/update/apply',{download_url:_updateUrl,tag:_updateTag}).then(function(){
|
||||||
sb.textContent=T.update_restarting;
|
sb.textContent=T.update_restarting;
|
||||||
@@ -1743,7 +1979,15 @@ function setStep(btn,v){
|
|||||||
currentStep=v;
|
currentStep=v;
|
||||||
document.querySelectorAll('.step-btn').forEach(b=>b.classList.remove('active'));
|
document.querySelectorAll('.step-btn').forEach(b=>b.classList.remove('active'));
|
||||||
btn.classList.add('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){
|
function move(axis,dir,dist){
|
||||||
// axis: 0=X,1=Y,2=Z → printer axis codes: 1=X,2=Y,3=Z
|
// axis: 0=X,1=Y,2=Z → printer axis codes: 1=X,2=Y,3=Z
|
||||||
@@ -1849,6 +2093,7 @@ function camStart(){
|
|||||||
post('/api/camera/start',{}).then(function(){
|
post('/api/camera/start',{}).then(function(){
|
||||||
camOn=true;
|
camOn=true;
|
||||||
document.getElementById('cam-toggle-btn').textContent=tr('btn_cam_stop');
|
document.getElementById('cam-toggle-btn').textContent=tr('btn_cam_stop');
|
||||||
|
var rb=document.getElementById('cam-reset-btn');if(rb)rb.style.display='';
|
||||||
clog(tr('log_cam_start'),'msg-ok');
|
clog(tr('log_cam_start'),'msg-ok');
|
||||||
setTimeout(function(){ sp.style.display='none'; img.style.display='block'; },1200);
|
setTimeout(function(){ sp.style.display='none'; img.style.display='block'; },1200);
|
||||||
if(/Android/i.test(navigator.userAgent)){
|
if(/Android/i.test(navigator.userAgent)){
|
||||||
@@ -1883,6 +2128,7 @@ function camStop(){
|
|||||||
camOn=false;
|
camOn=false;
|
||||||
camUserStopped=true; // suppress auto-restart for remainder of this print
|
camUserStopped=true; // suppress auto-restart for remainder of this print
|
||||||
document.getElementById('cam-toggle-btn').textContent=tr('btn_cam_start');
|
document.getElementById('cam-toggle-btn').textContent=tr('btn_cam_start');
|
||||||
|
var rb=document.getElementById('cam-reset-btn');if(rb)rb.style.display='none';
|
||||||
clog(tr('log_cam_stop'),'msg-ok');
|
clog(tr('log_cam_stop'),'msg-ok');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2133,6 +2379,13 @@ function aceDryToggle(aceId,on){
|
|||||||
|
|
||||||
function toggleCam(){if(camOn)camStop();else camStart()}
|
function toggleCam(){if(camOn)camStop();else camStart()}
|
||||||
|
|
||||||
|
function resetCamera(){
|
||||||
|
post('/api/camera/reset',{}).then(function(){
|
||||||
|
var btn=document.getElementById('cam-reset-btn');
|
||||||
|
if(btn){btn.textContent='↻';setTimeout(function(){btn.textContent='↺';},1000);}
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
|
||||||
function aceDryStop(aceId){
|
function aceDryStop(aceId){
|
||||||
aceId=(typeof aceId==='number'&&aceId>=0)?aceId:0;
|
aceId=(typeof aceId==='number'&&aceId>=0)?aceId:0;
|
||||||
return post('/api/ace/dry',{action:'stop',ace_id:aceId})
|
return post('/api/ace/dry',{action:'stop',ace_id:aceId})
|
||||||
@@ -2429,7 +2682,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
|
if(!_autoOpen) _fdAutoOpenedFile=null; // manueller Aufruf → Auto-Open-Sperre aufheben
|
||||||
var fn=filename||S.file_ready;
|
var fn=filename||S.file_ready;
|
||||||
var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;});
|
var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;});
|
||||||
@@ -2449,9 +2715,11 @@ function startReadyFileWithSlots(filename,_autoOpen){
|
|||||||
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){
|
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){
|
||||||
if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;}
|
if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;}
|
||||||
openFilamentDialog(d.result||[]);
|
openFilamentDialog(d.result||[]);
|
||||||
|
_showMismatchWarn(_mismatch||null);
|
||||||
}).catch(function(){
|
}).catch(function(){
|
||||||
if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;}
|
if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;}
|
||||||
openFilamentDialog([]);
|
openFilamentDialog([]);
|
||||||
|
_showMismatchWarn(_mismatch||null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2687,6 +2955,7 @@ function openFilamentDialog(slots){
|
|||||||
function closeFilamentDialog(){
|
function closeFilamentDialog(){
|
||||||
var dlg=document.getElementById('filament-dialog');
|
var dlg=document.getElementById('filament-dialog');
|
||||||
if(dlg)dlg.classList.remove('open');
|
if(dlg)dlg.classList.remove('open');
|
||||||
|
_showMismatchWarn(null);
|
||||||
_fdDialogOpen=false;
|
_fdDialogOpen=false;
|
||||||
if(_fdAutoOpenedFile){
|
if(_fdAutoOpenedFile){
|
||||||
_fdUserCancelled=true;
|
_fdUserCancelled=true;
|
||||||
@@ -3085,7 +3354,7 @@ function loadPrinterTab(){
|
|||||||
'<div style="font-size:12px;color:var(--txt2);display:flex;gap:12px">'+
|
'<div style="font-size:12px;color:var(--txt2);display:flex;gap:12px">'+
|
||||||
'<span>🌡 '+nt+'°C</span><span>🛏 '+bt+'°C</span>'+
|
'<span>🌡 '+nt+'°C</span><span>🛏 '+bt+'°C</span>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
(!isActive?'<a href="/printer'+printerNum+'" style="display:block;text-align:center;padding:7px;background:var(--accent);color:#fff;border-radius:7px;font-size:13px;font-weight:600;text-decoration:none;margin-top:4px">'+T.printers_switch+'</a>':'<div style="text-align:center;padding:7px;font-size:12px;color:var(--txt2)">'+T.printers_current+'</div>')+
|
(!isActive?'<a href="'+url+'/printer'+printerNum+'" style="display:block;text-align:center;padding:7px;background:var(--accent);color:#fff;border-radius:7px;font-size:13px;font-weight:600;text-decoration:none;margin-top:4px">'+T.printers_switch+'</a>':'<div style="text-align:center;padding:7px;font-size:12px;color:var(--txt2)">'+T.printers_current+'</div>')+
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>KX-Bridge</title>
|
<title>KX-Bridge</title>
|
||||||
<link rel="stylesheet" href="/kx/ui/style.css">
|
<link rel="stylesheet" href="/kx/ui/style.css">
|
||||||
|
<link rel="stylesheet" href="/kx/ui/lib/pickr-nano.min.css">
|
||||||
|
<script src="/kx/ui/lib/pickr.min.js"></script>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
|
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
|
||||||
@@ -32,17 +34,6 @@
|
|||||||
<span id="h-version" style="font-size:11px;opacity:.5;margin-left:6px"></span>
|
<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>
|
<div class="hbadge" id="h-badge"><span class="dot"></span><span id="h-state">Standby</span></div>
|
||||||
<button class="theme-btn" onclick="toggleTheme()">☀ / ☾</button>
|
<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="theme-btn" onclick="showPanel('settings')" id="settings-btn" title="Einstellungen">⚙</button>
|
||||||
<button class="conn-btn disconnected" id="conn-btn" onclick="toggleConnection()">⚡ Verbinden</button>
|
<button class="conn-btn disconnected" id="conn-btn" onclick="toggleConnection()">⚡ Verbinden</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -57,15 +48,26 @@
|
|||||||
<span class="modal-title" id="slot-edit-title"></span>
|
<span class="modal-title" id="slot-edit-title"></span>
|
||||||
<button onclick="closeSlotEdit()" style="background:none;border:none;color:var(--txt2);font-size:20px;cursor:pointer;line-height:1">✕</button>
|
<button onclick="closeSlotEdit()" style="background:none;border:none;color:var(--txt2);font-size:20px;cursor:pointer;line-height:1">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
|
<div style="display:flex;align-items:flex-start;gap:16px;margin-bottom:12px">
|
||||||
<div id="slot-edit-preview" style="width:56px;height:56px;border-radius:50%;border:3px solid rgba(255,255,255,.2);flex-shrink:0"></div>
|
<div id="slot-edit-preview" style="width:56px;height:56px;border-radius:50%;border:3px solid rgba(255,255,255,.2);flex-shrink:0;margin-top:4px"></div>
|
||||||
<div style="flex:1">
|
<div style="flex:1;min-width:0">
|
||||||
<div style="font-size:11px;color:var(--txt2);margin-bottom:4px" id="lbl-slot-color"></div>
|
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-color"></div>
|
||||||
<input type="color" id="slot-edit-color"
|
<!-- Pickr anchor — JS mounts the picker here -->
|
||||||
oninput="document.getElementById('slot-edit-preview').style.background=this.value"
|
<div id="slot-pickr-anchor"></div>
|
||||||
style="width:100%;height:36px;border:1px solid var(--border);border-radius:6px;background:var(--raised);cursor:pointer;padding:2px">
|
<!-- hidden input keeps the hex value for saveSlotEdit() -->
|
||||||
|
<input type="hidden" id="slot-edit-color">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Recent color swatches (max 16, localStorage) -->
|
||||||
|
<div id="slot-color-swatches" style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px"></div>
|
||||||
|
<!-- Copy from slot -->
|
||||||
|
<div id="slot-copy-row" style="display:none;margin-bottom:16px">
|
||||||
|
<select id="slot-copy-select"
|
||||||
|
style="width:100%;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:12px;box-sizing:border-box"
|
||||||
|
onchange="slotCopyColor(this)">
|
||||||
|
<option value="" id="lbl-slot-copy-from">Copy color from slot…</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div style="margin-bottom:20px">
|
<div style="margin-bottom:20px">
|
||||||
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-material"></div>
|
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-material"></div>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:6px" id="slot-mat-btns">
|
<div style="display:flex;flex-wrap:wrap;gap:6px" id="slot-mat-btns">
|
||||||
@@ -157,6 +159,7 @@
|
|||||||
<div style="font-size:12px;color:#fff" id="cam-fname"></div>
|
<div style="font-size:12px;color:#fff" id="cam-fname"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="cam-toggle" onclick="toggleCam()" id="cam-toggle-btn">▶ Kamera</button>
|
<button class="cam-toggle" onclick="toggleCam()" id="cam-toggle-btn">▶ Kamera</button>
|
||||||
|
<button class="cam-toggle" onclick="resetCamera()" id="cam-reset-btn" style="display:none;margin-left:4px" title="Kamera-Stream neu verbinden (nach 429-Sperre)">↺</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -251,39 +254,51 @@
|
|||||||
|
|
||||||
<!-- Achsensteuerung -->
|
<!-- Achsensteuerung -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title"><span>✛</span> <span id="ptitle-motion-xy">XY-Achsen</span></div>
|
<div class="card-title"><span>✛</span> <span id="ptitle-motion-xy">Achsensteuerung</span></div>
|
||||||
<div class="joypad">
|
<div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
|
||||||
<div></div>
|
<!-- XY -->
|
||||||
<button class="joy" onclick="move(1,1,getStep())" title="Y+">▲</button>
|
<div style="display:flex;flex-direction:column;align-items:center;gap:6px">
|
||||||
<div></div>
|
<div class="joypad">
|
||||||
<button class="joy" onclick="move(0,-1,getStep())" title="X−">◀</button>
|
<div></div>
|
||||||
<button class="joy home" onclick="homeAll()" title="Home All">⌂</button>
|
<button class="joy" onclick="move(1,1,getStep())" title="Y+">▲</button>
|
||||||
<button class="joy" onclick="move(0,1,getStep())" title="X+">▶</button>
|
<div></div>
|
||||||
<div></div>
|
<button class="joy" onclick="move(0,-1,getStep())" title="X−">◀</button>
|
||||||
<button class="joy" onclick="move(1,-1,getStep())" title="Y−">▼</button>
|
<div></div>
|
||||||
<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>
|
||||||
<div class="step-btns">
|
<!-- Einheitliche Step-Size -->
|
||||||
<button class="step-btn" onclick="setStep(this,0.1)">0.1</button>
|
<div style="display:flex;align-items:center;gap:6px;margin-top:10px;flex-wrap:wrap">
|
||||||
<button class="step-btn active" onclick="setStep(this,1)">1</button>
|
<div class="step-btns" style="margin-top:0">
|
||||||
<button class="step-btn" onclick="setStep(this,5)">5</button>
|
<button class="step-btn" onclick="setStep(this,0.1)">0.1</button>
|
||||||
<button class="step-btn" onclick="setStep(this,10)">10 mm</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>
|
||||||
<div class="home-btns">
|
<!-- Globale Befehle zentriert -->
|
||||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
|
<div style="display:flex;justify-content:center;gap:8px;margin-top:10px">
|
||||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
|
|
||||||
<button class="btn btn-sm btn-accent" onclick="homeAll()"><span class="lbl-home-all">Home All</span></button>
|
<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>
|
<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>
|
</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 -->
|
<!-- Print Speed -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -435,6 +450,7 @@
|
|||||||
<button class="set-cat" id="setcat-printer" onclick="showSettingsCat('printer')"><span>🖨</span> <span id="setcat-lbl-printer">Drucker</span></button>
|
<button class="set-cat" id="setcat-printer" onclick="showSettingsCat('printer')"><span>🖨</span> <span id="setcat-lbl-printer">Drucker</span></button>
|
||||||
<button class="set-cat" id="setcat-display" onclick="showSettingsCat('display')"><span>🎨</span> <span id="setcat-lbl-display">Darstellung</span></button>
|
<button class="set-cat" id="setcat-display" onclick="showSettingsCat('display')"><span>🎨</span> <span id="setcat-lbl-display">Darstellung</span></button>
|
||||||
<button class="set-cat" id="setcat-filament" onclick="showSettingsCat('filament')"><span>🧵</span> <span id="setcat-lbl-filament">Filament</span></button>
|
<button class="set-cat" id="setcat-filament" onclick="showSettingsCat('filament')"><span>🧵</span> <span id="setcat-lbl-filament">Filament</span></button>
|
||||||
|
<button class="set-cat" id="setcat-integrations" onclick="showSettingsCat('integrations')"><span>⚡</span> <span id="setcat-lbl-integrations">Integrationen</span></button>
|
||||||
<button class="set-cat" id="setcat-system" onclick="showSettingsCat('system')"><span>⚙</span> <span id="setcat-lbl-system">System</span></button>
|
<button class="set-cat" id="setcat-system" onclick="showSettingsCat('system')"><span>⚙</span> <span id="setcat-lbl-system">System</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -568,6 +584,39 @@
|
|||||||
<div id="visible-vendors-list" style="max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;padding:8px"></div>
|
<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>
|
<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>
|
||||||
|
<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 -->
|
||||||
|
<div class="set-group" id="setgrp-integrations">
|
||||||
|
<!-- Spoolman -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title"><span>🧵</span> <span id="modal-sec-spoolman">Spoolman</span></div>
|
||||||
|
<div class="set-row">
|
||||||
|
<label id="lbl-spoolman-url">Server-URL</label>
|
||||||
|
<input type="text" id="s-spoolman-url" placeholder="http://spoolman:7912" style="width:200px">
|
||||||
|
</div>
|
||||||
|
<div class="set-row">
|
||||||
|
<label id="lbl-spoolman-sync-rate">Sync-Rate (s, 0=Druckende)</label>
|
||||||
|
<input type="number" id="s-spoolman-sync-rate" min="0" max="3600" value="30" style="width:80px">
|
||||||
|
</div>
|
||||||
|
<div id="spoolman-status-row" style="margin-top:6px;font-size:12px;color:var(--txt2)">
|
||||||
|
<span id="spoolman-status-dot">●</span> <span id="spoolman-status-lbl"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Obico -->
|
||||||
|
<div class="card" style="margin-top:10px">
|
||||||
|
<div class="card-title"><span>🕵</span> <span id="modal-sec-obico">Obico</span></div>
|
||||||
|
<div style="font-size:12px;color:var(--txt2);line-height:1.6" id="obico-info-box">
|
||||||
|
Obico wird über den <code>moonraker-obico</code>-Container konfiguriert.<br>
|
||||||
|
Config-Datei: <code id="obico-cfg-path">/mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System -->
|
<!-- System -->
|
||||||
@@ -626,6 +675,7 @@
|
|||||||
<button onclick="closeFilamentDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)">✕</button>
|
<button onclick="closeFilamentDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="fd-slots-hint" style="font-size:12px;color:var(--txt2);margin-bottom:10px">GCode-Kanal → AMS-Slot zuweisen:</p>
|
<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-slots" style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px"></div>
|
||||||
<div id="fd-objects-section" style="display:none;margin-bottom:16px">
|
<div id="fd-objects-section" style="display:none;margin-bottom:16px">
|
||||||
<button type="button" id="fd-objects-toggle" onclick="toggleFdObjects()"
|
<button type="button" id="fd-objects-toggle" onclick="toggleFdObjects()"
|
||||||
|
|||||||
2
web/themes/default/lib/pickr-nano.min.css
vendored
Normal file
2
web/themes/default/lib/pickr-nano.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3
web/themes/default/lib/pickr.min.js
vendored
Normal file
3
web/themes/default/lib/pickr.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -15,10 +15,46 @@
|
|||||||
body{background:var(--bg);color:var(--txt);font-family:var(--font);font-size:14px;min-height:100vh;display:flex;flex-direction:column}
|
body{background:var(--bg);color:var(--txt);font-family:var(--font);font-size:14px;min-height:100vh;display:flex;flex-direction:column}
|
||||||
a{color:var(--accent);text-decoration:none}
|
a{color:var(--accent);text-decoration:none}
|
||||||
/* select/option-Farben explizit setzen — OrcaSlicers Device-Tab-Webview erbt
|
/* select/option-Farben explizit setzen — OrcaSlicers Device-Tab-Webview erbt
|
||||||
sie sonst nicht und rendert weiße Schrift auf weißem Grund (Issue #29). */
|
sie sonst nicht und rendert weiße Schrift auf weißem Grund (Issue #29).
|
||||||
select{background:var(--raised)!important;color:var(--txt)!important}
|
Einheitliches Styling für alle Dropdowns im gesamten UI. */
|
||||||
|
select{
|
||||||
|
background:var(--raised)!important;
|
||||||
|
color:var(--txt)!important;
|
||||||
|
border:1px solid var(--border)!important;
|
||||||
|
border-radius:8px!important;
|
||||||
|
padding:6px 10px!important;
|
||||||
|
font-size:13px!important;
|
||||||
|
appearance:none!important;
|
||||||
|
-webkit-appearance:none!important;
|
||||||
|
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23888' d='M6 8L0 0h12z'/%3E%3C/svg%3E")!important;
|
||||||
|
background-repeat:no-repeat!important;
|
||||||
|
background-position:right 10px center!important;
|
||||||
|
padding-right:28px!important;
|
||||||
|
cursor:pointer!important;
|
||||||
|
outline:none!important;
|
||||||
|
box-sizing:border-box!important;
|
||||||
|
}
|
||||||
|
select:focus{border-color:var(--accent)!important;box-shadow:0 0 0 2px rgba(0,200,255,0.18)!important}
|
||||||
select option{background:var(--card)!important;color:var(--txt)!important}
|
select option{background:var(--card)!important;color:var(--txt)!important}
|
||||||
|
|
||||||
|
/* Einheitliches Styling für Text/Number-Inputs */
|
||||||
|
input[type=text],input[type=number],input[type=url],input[type=password],input[type=email],input[type=search]{
|
||||||
|
background:var(--raised);
|
||||||
|
color:var(--txt);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
border-radius:8px;
|
||||||
|
padding:6px 10px;
|
||||||
|
font-size:13px;
|
||||||
|
outline:none;
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
input[type=text]:focus,input[type=number]:focus,input[type=url]:focus,
|
||||||
|
input[type=password]:focus,input[type=email]:focus,input[type=search]:focus{
|
||||||
|
border-color:var(--accent);
|
||||||
|
box-shadow:0 0 0 2px rgba(0,200,255,0.18);
|
||||||
|
}
|
||||||
|
input::placeholder{color:var(--txt2);opacity:1}
|
||||||
|
|
||||||
/* ── HEADER ── */
|
/* ── HEADER ── */
|
||||||
header{background:var(--card);border-bottom:1px solid var(--border);
|
header{background:var(--card);border-bottom:1px solid var(--border);
|
||||||
display:flex;align-items:center;gap:12px;padding:0 20px;height:52px;
|
display:flex;align-items:center;gap:12px;padding:0 20px;height:52px;
|
||||||
@@ -265,6 +301,8 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:
|
|||||||
.modal-field input{background:var(--raised);border:1px solid var(--border);
|
.modal-field input{background:var(--raised);border:1px solid var(--border);
|
||||||
border-radius:7px;color:var(--txt);padding:7px 10px;font-size:13px;width:100%}
|
border-radius:7px;color:var(--txt);padding:7px 10px;font-size:13px;width:100%}
|
||||||
.modal-field input:focus{outline:none;border-color:var(--accent)}
|
.modal-field input:focus{outline:none;border-color:var(--accent)}
|
||||||
|
.set-row{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
|
||||||
|
.set-row label{font-size:12px;color:var(--txt2)}
|
||||||
.poll-btns{display:flex;gap:8px}
|
.poll-btns{display:flex;gap:8px}
|
||||||
.poll-btn{flex:1;padding:7px;background:var(--raised);border:1px solid var(--border);
|
.poll-btn{flex:1;padding:7px;background:var(--raised);border:1px solid var(--border);
|
||||||
border-radius:7px;color:var(--txt2);cursor:pointer;font-size:13px;transition:all .15s}
|
border-radius:7px;color:var(--txt2);cursor:pointer;font-size:13px;transition:all .15s}
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"lbl_light": "💡 Licht",
|
"lbl_light": "💡 Licht",
|
||||||
"lbl_remaining": "Restzeit:",
|
"lbl_remaining": "Restzeit:",
|
||||||
"lbl_slicer_time": "Slicer-Schätzung:",
|
"lbl_slicer_time": "Slicer-Schätzung:",
|
||||||
|
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=Druckende)",
|
||||||
|
"lbl_spoolman_url": "Server-URL",
|
||||||
"lbl_unload": "Ausziehen",
|
"lbl_unload": "Ausziehen",
|
||||||
"lbl_zpos": "Z (mm)",
|
"lbl_zpos": "Z (mm)",
|
||||||
"log_auto": "⬇ Auto",
|
"log_auto": "⬇ Auto",
|
||||||
@@ -156,6 +158,8 @@
|
|||||||
"log_topic_label": "Thema:",
|
"log_topic_label": "Thema:",
|
||||||
"log_topic_print": "Druck",
|
"log_topic_print": "Druck",
|
||||||
"log_topic_status": "Status",
|
"log_topic_status": "Status",
|
||||||
|
"modal_sec_obico": "Obico",
|
||||||
|
"modal_sec_spoolman": "Spoolman",
|
||||||
"nav_ams": "AMS",
|
"nav_ams": "AMS",
|
||||||
"nav_browser": "Browser",
|
"nav_browser": "Browser",
|
||||||
"nav_console": "Konsole",
|
"nav_console": "Konsole",
|
||||||
@@ -185,8 +189,7 @@
|
|||||||
"panel_extras_camera": "Kamera",
|
"panel_extras_camera": "Kamera",
|
||||||
"panel_extras_fan": "Lüfter",
|
"panel_extras_fan": "Lüfter",
|
||||||
"panel_extras_light": "Licht",
|
"panel_extras_light": "Licht",
|
||||||
"panel_motion_xy": "XY-Achsen",
|
"panel_motion_xy": "Achsensteuerung",
|
||||||
"panel_motion_z": "Z-Achse",
|
|
||||||
"panel_print_btn_cancel": "✕ Abbrechen",
|
"panel_print_btn_cancel": "✕ Abbrechen",
|
||||||
"panel_print_btn_pause": "⏸ Pause",
|
"panel_print_btn_pause": "⏸ Pause",
|
||||||
"panel_print_btn_resume": "▶ Fortsetzen",
|
"panel_print_btn_resume": "▶ Fortsetzen",
|
||||||
@@ -231,6 +234,7 @@
|
|||||||
"settings_file_ready_banner": "Druckleiste",
|
"settings_file_ready_banner": "Druckleiste",
|
||||||
"settings_file_ready_dialog": "Druckdialog",
|
"settings_file_ready_dialog": "Druckdialog",
|
||||||
"settings_file_ready_mode": "Nach Upload: Druckstart-Verhalten",
|
"settings_file_ready_mode": "Nach Upload: Druckstart-Verhalten",
|
||||||
|
"settings_integrations": "Integrationen",
|
||||||
"settings_language": "Sprache",
|
"settings_language": "Sprache",
|
||||||
"settings_mode_id": "Mode-ID",
|
"settings_mode_id": "Mode-ID",
|
||||||
"settings_mode_id_placeholder": "20030",
|
"settings_mode_id_placeholder": "20030",
|
||||||
@@ -316,7 +320,10 @@
|
|||||||
"update_available": "verfügbar",
|
"update_available": "verfügbar",
|
||||||
"update_check": "Auf Updates prüfen",
|
"update_check": "Auf Updates prüfen",
|
||||||
"update_checking": "Prüfe...",
|
"update_checking": "Prüfe...",
|
||||||
|
"update_docker": "Befehl kopieren",
|
||||||
|
"update_docker_copied": "Kopiert! Ausführen: docker compose pull && docker compose up -d",
|
||||||
"update_error": "Fehler",
|
"update_error": "Fehler",
|
||||||
"update_none": "Bereits aktuell",
|
"update_none": "Bereits aktuell",
|
||||||
"update_restarting": "Starte neu..."
|
"update_restarting": "Starte neu...",
|
||||||
|
"slot_copy_from": "Farbe von Slot kopieren…"
|
||||||
}
|
}
|
||||||
@@ -123,6 +123,8 @@
|
|||||||
"lbl_light": "💡 Light",
|
"lbl_light": "💡 Light",
|
||||||
"lbl_remaining": "Remaining:",
|
"lbl_remaining": "Remaining:",
|
||||||
"lbl_slicer_time": "Slicer estimate:",
|
"lbl_slicer_time": "Slicer estimate:",
|
||||||
|
"lbl_spoolman_sync_rate": "Sync rate (s, 0=end of print)",
|
||||||
|
"lbl_spoolman_url": "Server URL",
|
||||||
"lbl_unload": "Unload",
|
"lbl_unload": "Unload",
|
||||||
"lbl_zpos": "Z (mm)",
|
"lbl_zpos": "Z (mm)",
|
||||||
"log_auto": "⬇ Auto",
|
"log_auto": "⬇ Auto",
|
||||||
@@ -156,6 +158,8 @@
|
|||||||
"log_topic_label": "Topic:",
|
"log_topic_label": "Topic:",
|
||||||
"log_topic_print": "Print",
|
"log_topic_print": "Print",
|
||||||
"log_topic_status": "Status",
|
"log_topic_status": "Status",
|
||||||
|
"modal_sec_obico": "Obico",
|
||||||
|
"modal_sec_spoolman": "Spoolman",
|
||||||
"nav_ams": "AMS",
|
"nav_ams": "AMS",
|
||||||
"nav_browser": "Browser",
|
"nav_browser": "Browser",
|
||||||
"nav_console": "Console",
|
"nav_console": "Console",
|
||||||
@@ -185,8 +189,7 @@
|
|||||||
"panel_extras_camera": "Camera",
|
"panel_extras_camera": "Camera",
|
||||||
"panel_extras_fan": "Fan",
|
"panel_extras_fan": "Fan",
|
||||||
"panel_extras_light": "Light",
|
"panel_extras_light": "Light",
|
||||||
"panel_motion_xy": "XY Axes",
|
"panel_motion_xy": "Axes Control",
|
||||||
"panel_motion_z": "Z Axis",
|
|
||||||
"panel_print_btn_cancel": "✕ Cancel",
|
"panel_print_btn_cancel": "✕ Cancel",
|
||||||
"panel_print_btn_pause": "⏸ Pause",
|
"panel_print_btn_pause": "⏸ Pause",
|
||||||
"panel_print_btn_resume": "▶ Resume",
|
"panel_print_btn_resume": "▶ Resume",
|
||||||
@@ -231,6 +234,7 @@
|
|||||||
"settings_file_ready_banner": "Print bar",
|
"settings_file_ready_banner": "Print bar",
|
||||||
"settings_file_ready_dialog": "Print dialog",
|
"settings_file_ready_dialog": "Print dialog",
|
||||||
"settings_file_ready_mode": "After upload: Start print behavior",
|
"settings_file_ready_mode": "After upload: Start print behavior",
|
||||||
|
"settings_integrations": "Integrations",
|
||||||
"settings_language": "Language",
|
"settings_language": "Language",
|
||||||
"settings_mode_id": "Mode ID",
|
"settings_mode_id": "Mode ID",
|
||||||
"settings_mode_id_placeholder": "20030",
|
"settings_mode_id_placeholder": "20030",
|
||||||
@@ -316,7 +320,10 @@
|
|||||||
"update_available": "available",
|
"update_available": "available",
|
||||||
"update_check": "Check for Updates",
|
"update_check": "Check for Updates",
|
||||||
"update_checking": "Checking...",
|
"update_checking": "Checking...",
|
||||||
|
"update_docker": "Copy Command",
|
||||||
|
"update_docker_copied": "Copied! Run: docker compose pull && docker compose up -d",
|
||||||
"update_error": "Error",
|
"update_error": "Error",
|
||||||
"update_none": "Already up to date",
|
"update_none": "Already up to date",
|
||||||
"update_restarting": "Restarting..."
|
"update_restarting": "Restarting...",
|
||||||
|
"slot_copy_from": "Copy color from slot…"
|
||||||
}
|
}
|
||||||
@@ -123,6 +123,8 @@
|
|||||||
"lbl_light": "💡 Luz",
|
"lbl_light": "💡 Luz",
|
||||||
"lbl_remaining": "Restante:",
|
"lbl_remaining": "Restante:",
|
||||||
"lbl_slicer_time": "Estimación del slicer:",
|
"lbl_slicer_time": "Estimación del slicer:",
|
||||||
|
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=fin impresión)",
|
||||||
|
"lbl_spoolman_url": "URL del servidor",
|
||||||
"lbl_unload": "Descargar",
|
"lbl_unload": "Descargar",
|
||||||
"lbl_zpos": "Z (mm)",
|
"lbl_zpos": "Z (mm)",
|
||||||
"log_auto": "⬇ Auto",
|
"log_auto": "⬇ Auto",
|
||||||
@@ -156,6 +158,8 @@
|
|||||||
"log_topic_label": "Tema:",
|
"log_topic_label": "Tema:",
|
||||||
"log_topic_print": "Impresión",
|
"log_topic_print": "Impresión",
|
||||||
"log_topic_status": "Estado",
|
"log_topic_status": "Estado",
|
||||||
|
"modal_sec_obico": "Obico",
|
||||||
|
"modal_sec_spoolman": "Spoolman",
|
||||||
"nav_ams": "AMS",
|
"nav_ams": "AMS",
|
||||||
"nav_browser": "Explorador",
|
"nav_browser": "Explorador",
|
||||||
"nav_console": "Consola",
|
"nav_console": "Consola",
|
||||||
@@ -185,8 +189,7 @@
|
|||||||
"panel_extras_camera": "Cámara",
|
"panel_extras_camera": "Cámara",
|
||||||
"panel_extras_fan": "Ventilador",
|
"panel_extras_fan": "Ventilador",
|
||||||
"panel_extras_light": "Luz",
|
"panel_extras_light": "Luz",
|
||||||
"panel_motion_xy": "Ejes XY",
|
"panel_motion_xy": "Control de Ejes",
|
||||||
"panel_motion_z": "Eje Z",
|
|
||||||
"panel_print_btn_cancel": "✕ Cancelar",
|
"panel_print_btn_cancel": "✕ Cancelar",
|
||||||
"panel_print_btn_pause": "⏸ Pausa",
|
"panel_print_btn_pause": "⏸ Pausa",
|
||||||
"panel_print_btn_resume": "▶ Reanudar",
|
"panel_print_btn_resume": "▶ Reanudar",
|
||||||
@@ -231,6 +234,7 @@
|
|||||||
"settings_file_ready_banner": "Barra de impresión",
|
"settings_file_ready_banner": "Barra de impresión",
|
||||||
"settings_file_ready_dialog": "Diálogo de impresión",
|
"settings_file_ready_dialog": "Diálogo de impresión",
|
||||||
"settings_file_ready_mode": "Después de carga: Comportamiento de inicio de impresión",
|
"settings_file_ready_mode": "Después de carga: Comportamiento de inicio de impresión",
|
||||||
|
"settings_integrations": "Integraciones",
|
||||||
"settings_language": "Idioma",
|
"settings_language": "Idioma",
|
||||||
"settings_mode_id": "ID de modo",
|
"settings_mode_id": "ID de modo",
|
||||||
"settings_mode_id_placeholder": "20030",
|
"settings_mode_id_placeholder": "20030",
|
||||||
@@ -316,7 +320,10 @@
|
|||||||
"update_available": "disponible",
|
"update_available": "disponible",
|
||||||
"update_check": "Buscar actualizaciones",
|
"update_check": "Buscar actualizaciones",
|
||||||
"update_checking": "Comprobando...",
|
"update_checking": "Comprobando...",
|
||||||
|
"update_docker": "Copiar comando",
|
||||||
|
"update_docker_copied": "Copiado. Ejecutar: docker compose pull && docker compose up -d",
|
||||||
"update_error": "Error",
|
"update_error": "Error",
|
||||||
"update_none": "Ya actualizado",
|
"update_none": "Ya actualizado",
|
||||||
"update_restarting": "Reiniciando..."
|
"update_restarting": "Reiniciando...",
|
||||||
|
"slot_copy_from": "Copiar color del slot…"
|
||||||
}
|
}
|
||||||
@@ -123,6 +123,8 @@
|
|||||||
"lbl_light": "💡 Lumière",
|
"lbl_light": "💡 Lumière",
|
||||||
"lbl_remaining": "Restant :",
|
"lbl_remaining": "Restant :",
|
||||||
"lbl_slicer_time": "Estimation slicer :",
|
"lbl_slicer_time": "Estimation slicer :",
|
||||||
|
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=fin impression)",
|
||||||
|
"lbl_spoolman_url": "URL du serveur",
|
||||||
"lbl_unload": "Décharger",
|
"lbl_unload": "Décharger",
|
||||||
"lbl_zpos": "Z (mm)",
|
"lbl_zpos": "Z (mm)",
|
||||||
"log_auto": "⬇ Auto",
|
"log_auto": "⬇ Auto",
|
||||||
@@ -156,6 +158,8 @@
|
|||||||
"log_topic_label": "Sujet :",
|
"log_topic_label": "Sujet :",
|
||||||
"log_topic_print": "Impression",
|
"log_topic_print": "Impression",
|
||||||
"log_topic_status": "Statut",
|
"log_topic_status": "Statut",
|
||||||
|
"modal_sec_obico": "Obico",
|
||||||
|
"modal_sec_spoolman": "Spoolman",
|
||||||
"nav_ams": "AMS",
|
"nav_ams": "AMS",
|
||||||
"nav_browser": "Navigateur",
|
"nav_browser": "Navigateur",
|
||||||
"nav_console": "Console",
|
"nav_console": "Console",
|
||||||
@@ -185,8 +189,7 @@
|
|||||||
"panel_extras_camera": "Caméra",
|
"panel_extras_camera": "Caméra",
|
||||||
"panel_extras_fan": "Ventilateur",
|
"panel_extras_fan": "Ventilateur",
|
||||||
"panel_extras_light": "Lumière",
|
"panel_extras_light": "Lumière",
|
||||||
"panel_motion_xy": "Axes XY",
|
"panel_motion_xy": "Contrôle des Axes",
|
||||||
"panel_motion_z": "Axe Z",
|
|
||||||
"panel_print_btn_cancel": "✕ Annuler",
|
"panel_print_btn_cancel": "✕ Annuler",
|
||||||
"panel_print_btn_pause": "⏸ Pause",
|
"panel_print_btn_pause": "⏸ Pause",
|
||||||
"panel_print_btn_resume": "▶ Reprendre",
|
"panel_print_btn_resume": "▶ Reprendre",
|
||||||
@@ -231,6 +234,7 @@
|
|||||||
"settings_file_ready_banner": "Barre d'impression",
|
"settings_file_ready_banner": "Barre d'impression",
|
||||||
"settings_file_ready_dialog": "Dialogue d'impression",
|
"settings_file_ready_dialog": "Dialogue d'impression",
|
||||||
"settings_file_ready_mode": "Après téléchargement : Comportement de démarrage d'impression",
|
"settings_file_ready_mode": "Après téléchargement : Comportement de démarrage d'impression",
|
||||||
|
"settings_integrations": "Intégrations",
|
||||||
"settings_language": "Langue",
|
"settings_language": "Langue",
|
||||||
"settings_mode_id": "ID du mode",
|
"settings_mode_id": "ID du mode",
|
||||||
"settings_mode_id_placeholder": "20030",
|
"settings_mode_id_placeholder": "20030",
|
||||||
@@ -316,7 +320,10 @@
|
|||||||
"update_available": "disponible",
|
"update_available": "disponible",
|
||||||
"update_check": "Vérifier les mises à jour",
|
"update_check": "Vérifier les mises à jour",
|
||||||
"update_checking": "Vérification…",
|
"update_checking": "Vérification…",
|
||||||
|
"update_docker": "Copier la commande",
|
||||||
|
"update_docker_copied": "Copié ! Exécuter : docker compose pull && docker compose up -d",
|
||||||
"update_error": "Erreur",
|
"update_error": "Erreur",
|
||||||
"update_none": "Déjà à jour",
|
"update_none": "Déjà à jour",
|
||||||
"update_restarting": "Redémarrage…"
|
"update_restarting": "Redémarrage…",
|
||||||
|
"slot_copy_from": "Copier la couleur du slot…"
|
||||||
}
|
}
|
||||||
@@ -123,6 +123,8 @@
|
|||||||
"lbl_light": "💡 Luce",
|
"lbl_light": "💡 Luce",
|
||||||
"lbl_remaining": "Rimanente:",
|
"lbl_remaining": "Rimanente:",
|
||||||
"lbl_slicer_time": "Stima slicer:",
|
"lbl_slicer_time": "Stima slicer:",
|
||||||
|
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=fine stampa)",
|
||||||
|
"lbl_spoolman_url": "URL server",
|
||||||
"lbl_unload": "Rimuovi",
|
"lbl_unload": "Rimuovi",
|
||||||
"lbl_zpos": "Z (mm)",
|
"lbl_zpos": "Z (mm)",
|
||||||
"log_auto": "⬇ Auto",
|
"log_auto": "⬇ Auto",
|
||||||
@@ -156,6 +158,8 @@
|
|||||||
"log_topic_label": "Argomento:",
|
"log_topic_label": "Argomento:",
|
||||||
"log_topic_print": "Stampa",
|
"log_topic_print": "Stampa",
|
||||||
"log_topic_status": "Stato",
|
"log_topic_status": "Stato",
|
||||||
|
"modal_sec_obico": "Obico",
|
||||||
|
"modal_sec_spoolman": "Spoolman",
|
||||||
"nav_ams": "AMS",
|
"nav_ams": "AMS",
|
||||||
"nav_browser": "Browser",
|
"nav_browser": "Browser",
|
||||||
"nav_console": "Console",
|
"nav_console": "Console",
|
||||||
@@ -185,8 +189,7 @@
|
|||||||
"panel_extras_camera": "Camera",
|
"panel_extras_camera": "Camera",
|
||||||
"panel_extras_fan": "Ventola",
|
"panel_extras_fan": "Ventola",
|
||||||
"panel_extras_light": "Luce",
|
"panel_extras_light": "Luce",
|
||||||
"panel_motion_xy": "Assi XY",
|
"panel_motion_xy": "Controllo Assi",
|
||||||
"panel_motion_z": "Asse Z",
|
|
||||||
"panel_print_btn_cancel": "✕ Annulla",
|
"panel_print_btn_cancel": "✕ Annulla",
|
||||||
"panel_print_btn_pause": "⏸ Pausa",
|
"panel_print_btn_pause": "⏸ Pausa",
|
||||||
"panel_print_btn_resume": "▶ Riprendi",
|
"panel_print_btn_resume": "▶ Riprendi",
|
||||||
@@ -231,6 +234,7 @@
|
|||||||
"settings_file_ready_banner": "Barra di stampa",
|
"settings_file_ready_banner": "Barra di stampa",
|
||||||
"settings_file_ready_dialog": "Finestra di dialogo stampa",
|
"settings_file_ready_dialog": "Finestra di dialogo stampa",
|
||||||
"settings_file_ready_mode": "Dopo il caricamento: Comportamento di avvio stampa",
|
"settings_file_ready_mode": "Dopo il caricamento: Comportamento di avvio stampa",
|
||||||
|
"settings_integrations": "Integrazioni",
|
||||||
"settings_language": "Lingua",
|
"settings_language": "Lingua",
|
||||||
"settings_mode_id": "ID modalità",
|
"settings_mode_id": "ID modalità",
|
||||||
"settings_mode_id_placeholder": "20030",
|
"settings_mode_id_placeholder": "20030",
|
||||||
@@ -316,7 +320,10 @@
|
|||||||
"update_available": "disponibile",
|
"update_available": "disponibile",
|
||||||
"update_check": "Controlla aggiornamenti",
|
"update_check": "Controlla aggiornamenti",
|
||||||
"update_checking": "Verifica in corso...",
|
"update_checking": "Verifica in corso...",
|
||||||
|
"update_docker": "Copia comando",
|
||||||
|
"update_docker_copied": "Copiato! Eseguire: docker compose pull && docker compose up -d",
|
||||||
"update_error": "Errore",
|
"update_error": "Errore",
|
||||||
"update_none": "Già aggiornato",
|
"update_none": "Già aggiornato",
|
||||||
"update_restarting": "Riavvio in corso..."
|
"update_restarting": "Riavvio in corso...",
|
||||||
|
"slot_copy_from": "Copia colore dallo slot…"
|
||||||
}
|
}
|
||||||
@@ -123,6 +123,8 @@
|
|||||||
"lbl_light": "💡 灯光",
|
"lbl_light": "💡 灯光",
|
||||||
"lbl_remaining": "剩余时间:",
|
"lbl_remaining": "剩余时间:",
|
||||||
"lbl_slicer_time": "切片预估:",
|
"lbl_slicer_time": "切片预估:",
|
||||||
|
"lbl_spoolman_sync_rate": "同步频率(秒,0=打印结束)",
|
||||||
|
"lbl_spoolman_url": "服务器地址",
|
||||||
"lbl_unload": "退料",
|
"lbl_unload": "退料",
|
||||||
"lbl_zpos": "Z (mm)",
|
"lbl_zpos": "Z (mm)",
|
||||||
"log_auto": "⬇ 自动",
|
"log_auto": "⬇ 自动",
|
||||||
@@ -156,6 +158,8 @@
|
|||||||
"log_topic_label": "主题:",
|
"log_topic_label": "主题:",
|
||||||
"log_topic_print": "打印",
|
"log_topic_print": "打印",
|
||||||
"log_topic_status": "状态",
|
"log_topic_status": "状态",
|
||||||
|
"modal_sec_obico": "Obico",
|
||||||
|
"modal_sec_spoolman": "Spoolman",
|
||||||
"nav_ams": "AMS",
|
"nav_ams": "AMS",
|
||||||
"nav_browser": "浏览器",
|
"nav_browser": "浏览器",
|
||||||
"nav_console": "控制台",
|
"nav_console": "控制台",
|
||||||
@@ -185,8 +189,7 @@
|
|||||||
"panel_extras_camera": "相机",
|
"panel_extras_camera": "相机",
|
||||||
"panel_extras_fan": "风扇",
|
"panel_extras_fan": "风扇",
|
||||||
"panel_extras_light": "灯光",
|
"panel_extras_light": "灯光",
|
||||||
"panel_motion_xy": "XY 轴",
|
"panel_motion_xy": "轴控制",
|
||||||
"panel_motion_z": "Z 轴",
|
|
||||||
"panel_print_btn_cancel": "✕ 取消",
|
"panel_print_btn_cancel": "✕ 取消",
|
||||||
"panel_print_btn_pause": "⏸ 暂停",
|
"panel_print_btn_pause": "⏸ 暂停",
|
||||||
"panel_print_btn_resume": "▶ 继续",
|
"panel_print_btn_resume": "▶ 继续",
|
||||||
@@ -231,6 +234,7 @@
|
|||||||
"settings_file_ready_banner": "打印栏",
|
"settings_file_ready_banner": "打印栏",
|
||||||
"settings_file_ready_dialog": "打印对话框",
|
"settings_file_ready_dialog": "打印对话框",
|
||||||
"settings_file_ready_mode": "上传后:开始打印行为",
|
"settings_file_ready_mode": "上传后:开始打印行为",
|
||||||
|
"settings_integrations": "集成",
|
||||||
"settings_language": "语言",
|
"settings_language": "语言",
|
||||||
"settings_mode_id": "模式 ID",
|
"settings_mode_id": "模式 ID",
|
||||||
"settings_mode_id_placeholder": "20030",
|
"settings_mode_id_placeholder": "20030",
|
||||||
@@ -316,7 +320,10 @@
|
|||||||
"update_available": "可用",
|
"update_available": "可用",
|
||||||
"update_check": "检查更新",
|
"update_check": "检查更新",
|
||||||
"update_checking": "检查中...",
|
"update_checking": "检查中...",
|
||||||
|
"update_docker": "复制命令",
|
||||||
|
"update_docker_copied": "已复制!执行:docker compose pull && docker compose up -d",
|
||||||
"update_error": "错误",
|
"update_error": "错误",
|
||||||
"update_none": "已是最新版本",
|
"update_none": "已是最新版本",
|
||||||
"update_restarting": "重启中..."
|
"update_restarting": "重启中...",
|
||||||
|
"slot_copy_from": "从插槽复制颜色…"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user