Compare commits
52 Commits
nightly-0.
...
nightly
| Author | SHA1 | Date | |
|---|---|---|---|
| 99bc1797c8 | |||
| e1b9480098 | |||
|
|
03089db6af | ||
|
|
cd4a8ce48e | ||
| bf3f043888 | |||
| b9594d4a22 | |||
|
|
bf94a8f563 | ||
| eda14db897 | |||
| dcf852db49 | |||
| d2c92c2deb | |||
| b541aafc74 | |||
| f2f4447809 | |||
| a3deb33b97 | |||
| 2e2061a269 | |||
| e4bf2c9b95 | |||
| aea6e457f3 | |||
| 0ae8ae59be | |||
| 0322ade606 | |||
| 786fa08ca0 | |||
| cd11542352 | |||
| 51f22947c5 | |||
|
|
a39226d2dd | ||
|
|
2a13f1f0dd | ||
| a16062f44f | |||
| 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 |
12
.gitea/make_release_json.py
Normal file
12
.gitea/make_release_json.py
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys, json
|
||||
tag, version, body_file = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
body = open(body_file).read()
|
||||
payload = json.dumps({
|
||||
"tag_name": tag,
|
||||
"name": "KX-Bridge " + version + " Nightly",
|
||||
"body": body,
|
||||
"draft": False,
|
||||
"prerelease": True
|
||||
})
|
||||
open("/tmp/release_body.json", "w").write(payload)
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- 'requirements.txt'
|
||||
- 'web/**'
|
||||
- 'data/**'
|
||||
- '.gitea/workflows/nightly.yml'
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -21,11 +22,11 @@ jobs:
|
||||
- name: Checkout
|
||||
run: |
|
||||
if [ -d .git ]; then
|
||||
git fetch origin nightly
|
||||
git fetch --tags origin nightly
|
||||
git reset --hard origin/nightly
|
||||
git clean -fd
|
||||
else
|
||||
git clone --depth=1 --branch nightly https://gitea.it-drui.de/viewit/KX-Bridge-Release.git .
|
||||
git clone --branch nightly https://gitea.it-drui.de/viewit/KX-Bridge-Release.git .
|
||||
fi
|
||||
|
||||
- name: Install Docker CLI
|
||||
@@ -64,11 +65,36 @@ jobs:
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | \
|
||||
docker login gitea.it-drui.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Compute nightly version
|
||||
run: |
|
||||
# Letzten Stable-Tag ermitteln (v0.9.27 → minor=27)
|
||||
LAST_STABLE=$(git tag --list 'v*' --sort=-version:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | { read -r line; echo "$line"; cat >/dev/null; } || true)
|
||||
if [ -z "$LAST_STABLE" ]; then
|
||||
echo "ERROR: kein Stable-Tag gefunden" >&2; exit 1
|
||||
fi
|
||||
# Nächste Minor-Version: v0.9.27 → 0.9.28
|
||||
MAJOR=$(echo "$LAST_STABLE" | sed 's/^v//' | cut -d. -f1)
|
||||
MINOR=$(echo "$LAST_STABLE" | sed 's/^v//' | cut -d. -f2)
|
||||
PATCH=$(echo "$LAST_STABLE" | sed 's/^v//' | cut -d. -f3)
|
||||
NEXT_PATCH=$((PATCH + 1))
|
||||
BASE="${MAJOR}.${MINOR}.${NEXT_PATCH}"
|
||||
# Laufende Nummer: Anzahl vorhandener nightly-<BASE>-nightlyX Tags + 1
|
||||
COUNT=$(git tag --list "nightly-${BASE}-nightly*" | wc -l | tr -d ' ')
|
||||
N=$((COUNT + 1))
|
||||
VERSION="${BASE}-nightly${N}"
|
||||
echo "VERSION=${VERSION}" > /tmp/nightly_version.env
|
||||
echo "BASE=${BASE}" >> /tmp/nightly_version.env
|
||||
echo "LAST_STABLE=${LAST_STABLE}" >> /tmp/nightly_version.env
|
||||
echo "Computed nightly version: ${VERSION} (after ${LAST_STABLE})"
|
||||
|
||||
- name: Build & push (amd64 + arm64)
|
||||
run: |
|
||||
VERSION=$(cat VERSION)
|
||||
. /tmp/nightly_version.env
|
||||
# VERSION-Datei im Arbeitsverzeichnis für den Docker-Build setzen (kein Commit)
|
||||
echo "$VERSION" > VERSION
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
--push \
|
||||
--provenance=false \
|
||||
--no-cache \
|
||||
@@ -80,50 +106,68 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(cat VERSION)
|
||||
. /tmp/nightly_version.env
|
||||
TAG="nightly-${VERSION}"
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@it-drui.de"
|
||||
|
||||
# Changelog aus CHANGES.md lesen (wird von release.sh aus dem Dev-Repo generiert)
|
||||
# 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)
|
||||
if [ -f CHANGES.md ]; then
|
||||
cat CHANGES.md > "$BODY_FILE"
|
||||
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
|
||||
# Fallback falls CHANGES.md fehlt
|
||||
printf '## KX-Bridge %s -- Nightly Build\n\n' "$VERSION" > "$BODY_FILE"
|
||||
printf '[experimentell] Ungetestete Features, nur fuer Tester geeignet.\n\n' >> "$BODY_FILE"
|
||||
printf '- Automatischer Nightly-Build\n\n---\n\n' >> "$BODY_FILE"
|
||||
printf '### Docker-Image aktualisieren\n\n```bash\ndocker compose pull && docker compose up -d\n```\n\n' >> "$BODY_FILE"
|
||||
printf 'Image-Tag: `gitea.it-drui.de/viewit/kx-bridge:nightly`\n' >> "$BODY_FILE"
|
||||
fi
|
||||
|
||||
# Tag setzen
|
||||
git tag -f "$TAG"
|
||||
git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git "$TAG" --force
|
||||
|
||||
# curl installieren (BusyBox wget kann kein DELETE/POST mit Headers)
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
if ! apk add --no-cache curl 2>/dev/null; then
|
||||
wget -qO /usr/local/bin/curl \
|
||||
"https://github.com/moparisthebest/static-curl/releases/download/v8.6.0/curl-amd64"
|
||||
chmod +x /usr/local/bin/curl
|
||||
printf '### Changes since `%s`\n\n' "$PREV_TAG" >> "$BODY_FILE"
|
||||
git log "${PREV_TAG}..HEAD" --pretty=format:'%s' --no-merges \
|
||||
| grep -E '^(feat|fix)[:(]' \
|
||||
| grep -Ev '^(feat|fix)\((ci|release|build|workflow)\)' \
|
||||
| sed 's/^/- /' \
|
||||
>> "$BODY_FILE" || true
|
||||
if ! grep -q '^- ' "$BODY_FILE"; then
|
||||
printf '- No user-facing changes in this build\n' >> "$BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
printf '\n\n---\n\n### Update Docker image\n\n```bash\ndocker compose pull && docker compose up -d\n```\n\n' >> "$BODY_FILE"
|
||||
printf 'Image tag: `gitea.it-drui.de/viewit/kx-bridge:nightly`\n' >> "$BODY_FILE"
|
||||
|
||||
# Altes Release loeschen falls vorhanden
|
||||
# Tag setzen
|
||||
git tag "$TAG"
|
||||
git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git "$TAG"
|
||||
|
||||
# curl + 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-Body via awk escapen)
|
||||
BODY_JSON=$(awk '{
|
||||
gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t");
|
||||
printf "%s\\n", $0
|
||||
}' "$BODY_FILE" | awk 'BEGIN{printf "\""} {printf "%s", $0} END{printf "\""}')
|
||||
JSON_PAYLOAD="{\"tag_name\":\"${TAG}\",\"name\":\"KX-Bridge ${VERSION} Nightly\",\"body\":${BODY_JSON},\"draft\":false,\"prerelease\":true}"
|
||||
printf '%s' "$JSON_PAYLOAD" > /tmp/release_body.json
|
||||
# Release erstellen — JSON sicher via jq bauen
|
||||
jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg name "KX-Bridge ${VERSION} Nightly" \
|
||||
--rawfile body "$BODY_FILE" \
|
||||
'{"tag_name":$tag,"name":$name,"body":$body,"draft":false,"prerelease":true}' \
|
||||
> /tmp/release_body.json
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
--push \
|
||||
--provenance=false \
|
||||
--no-cache \
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,9 +4,7 @@ __pycache__/
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
releases/*/kx-bridge
|
||||
releases/*/extract_credentials
|
||||
releases/*/extract_credentials.exe
|
||||
releases/
|
||||
|
||||
!kx-bridge.spec
|
||||
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Slot kept showing/printing a stale filament type after a spool swap.** The
|
||||
per-slot profile override (config.ini `[filament_profiles]`) stores only
|
||||
vendor+name and was sticky: swapping the physical filament updated the AMS
|
||||
colour and type live, but the saved profile persisted, so a slot that held
|
||||
e.g. "KINGROON PETG Basic" kept showing/sending PETG in the panel and the
|
||||
OrcaSlicer lane hint even after yellow PLA was loaded — and survived restarts.
|
||||
The override is now applied only while its material *family* still matches the
|
||||
loaded AMS material (PLA / PLA+ / PLA SILK / PLA MATTE are one family, so
|
||||
within-family swaps never invalidate a valid profile). On a family change the
|
||||
slot falls back to the generic default; the override is not deleted, so
|
||||
reloading the original material reactivates it.
|
||||
- **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
|
||||
|
||||
### New
|
||||
|
||||
33
CHANGES.md
33
CHANGES.md
@@ -1,33 +0,0 @@
|
||||
## KX-Bridge 0.9.27-nightly9 — Nightly Build
|
||||
|
||||
[experimentell] Ungetestete Features, nur für Tester geeignet.
|
||||
|
||||
### Änderungen seit `v0.9.26`
|
||||
|
||||
- fix(spoolman): Status-Dot beim Seitenload initialisieren
|
||||
- chore: CHANGES.md mit echtem Changelog aus Dev-Repo ins Release-Repo schreiben
|
||||
- fix(spoolman): SPOOLMAN_* env-Cache bei Restart leeren, Status-Dot nach Save aktualisieren
|
||||
- chore: README.es.md + CONTRIBUTING.md in release.sh-Sync aufnehmen
|
||||
- docs: Nightly-Sektion, Wartungshinweis + CONTRIBUTING.md, FR/IT-Sprachen, Downloads-Badge
|
||||
- fix(update): Nightly-Vergleich auf Versions-String umstellen (statt Datum)
|
||||
- feat(update): Nightly-Track vom Stable-Track trennen
|
||||
- fix(release): nightly immer auf nightly-Branch pushen, kein master-Push
|
||||
- fix(camera): exponentielles Backoff bei ffmpeg-Fehler + /api/camera/reset + ↺-Button
|
||||
- fix(release): Nightly-Release vom nightly-Branch erlauben
|
||||
- feat(i18n): fehlende UI-Übersetzungen ergänzt + Keys alphabetisch sortiert (PR #70 @fenopy)
|
||||
- fix: config/config.ini.example beim Release-Sync mitübertragen (Issue #72)
|
||||
- feat(ui): Integrationen-Tab in Settings (Spoolman + Obico-Hinweis)
|
||||
- feat(stack): KobraX Full Stack Compose für Portainer (KX-Bridge + Obico + Spoolman)
|
||||
- feat(release): Nightly/Stable Release-Workflow mit eigenem Docker-Tag
|
||||
- feat(spoolman): optionale Spoolman-Filamentverbrauch-Integration (PR #65, @p2l)
|
||||
- fix(release): Artifact-Download per HTTP statt lokalem Dateipfad
|
||||
|
||||
---
|
||||
|
||||
### Docker-Image aktualisieren
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Image-Tag: `gitea.it-drui.de/viewit/kx-bridge:nightly`
|
||||
@@ -2,10 +2,11 @@ FROM python:3.11-slim-bookworm
|
||||
|
||||
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 .
|
||||
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 web/ ./web/
|
||||
|
||||
5
NIGHTLY_CHANGELOG.md
Normal file
5
NIGHTLY_CHANGELOG.md
Normal file
@@ -0,0 +1,5 @@
|
||||
## Changes in this build
|
||||
|
||||
- Fix: dashboard reprint from the cache path crashed with an AttributeError (`self._db` did not exist) instead of applying the filament slot filter (PR #93, thanks @Pavulon87)
|
||||
- Feat: the dashboard now shows a banner with the reason and error code when a print is paused, with a direct link to the Anycubic error-code docs (PR #92, thanks @Pavulon87)
|
||||
- Fix: `/api/camera/stream` (the MJPEG live view used by the dashboard and every Moonraker-compatible client) opened a brand-new ffmpeg process and printer connection per HTTP client — two simultaneous viewers could already exhaust the printer's very limited camera connection slots and cause intermittent "stream unavailable"/429 failures. All consumers now share one ffmpeg process via the same fanout pattern already used for the H.264 stream (credit to @Pavulon87 for the fix and for finding two related race conditions along the way)
|
||||
@@ -41,6 +41,21 @@ web_upload_warning = 1
|
||||
# Poll-Intervall in Sekunden
|
||||
poll_interval = 3
|
||||
|
||||
# ─── Spoolman (optional) ───────────────────────────────────────────────────────
|
||||
# Verfolgt den Filamentverbrauch je AMS-Slot und bucht ihn automatisch vom
|
||||
# passenden Spool ab (mm-basiert, wie Moonraker; Spoolman rechnet mm→Gramm).
|
||||
# [spoolman]
|
||||
# # Server-URL der Spoolman-Instanz (aus Sicht des Bridge-Containers erreichbar):
|
||||
# server = http://192.168.x.x:7912
|
||||
# # 0 = nur am Druckende abbuchen, >0 = alle N Sekunden während des Drucks:
|
||||
# sync_rate = 0
|
||||
#
|
||||
# Die AMS-Slot → Spool-Zuordnung wird in der Weboberfläche gesetzt und je Drucker
|
||||
# automatisch persistiert (nicht von Hand eintragen):
|
||||
# Einzeldrucker : [spoolman] slot_spools = 0:42,1:17
|
||||
# Multi-Printer : [spoolman_1] slot_spools = 0:42,1:17
|
||||
# [spoolman_2] slot_spools = 0:5,1:6
|
||||
|
||||
# ─── 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).
|
||||
|
||||
287
config_loader.py
287
config_loader.py
@@ -1,12 +1,13 @@
|
||||
"""
|
||||
config_loader.py – lädt Verbindungsparameter aus config/config.ini (primär)
|
||||
oder .env (Fallback / Migration).
|
||||
Umgebungsvariablen haben immer Vorrang.
|
||||
config_loader.py - loads connection parameters from config/config.ini (primary)
|
||||
or .env (fallback / migration).
|
||||
Environment variables always take precedence.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import pathlib
|
||||
import configparser
|
||||
from typing import Optional
|
||||
|
||||
_BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) else pathlib.Path(__file__).parent
|
||||
|
||||
@@ -33,7 +34,7 @@ def _find_env_file() -> pathlib.Path | None:
|
||||
|
||||
|
||||
def _load_env_file(path: pathlib.Path):
|
||||
"""Lädt .env-Datei als Fallback – setzt nur Keys die noch nicht in os.environ sind."""
|
||||
"""Loads the .env file as a fallback - only sets keys not yet in os.environ."""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
@@ -46,28 +47,39 @@ def _load_env_file(path: pathlib.Path):
|
||||
os.environ[key] = val
|
||||
|
||||
|
||||
# Single source of truth for env-var <-> config.ini mapping. _restart_bridge()
|
||||
# in kobrax_moonraker_bridge.py clears exactly these keys from os.environ
|
||||
# before restarting, so a value removed here or in the UI settings save can
|
||||
# never survive as a stale env var read by the new process. Add new settings
|
||||
# here ONLY - no second list to keep in sync.
|
||||
CONFIG_ENV_MAPPING = {
|
||||
"PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"),
|
||||
"MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"),
|
||||
"MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"),
|
||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
|
||||
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
|
||||
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
|
||||
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
|
||||
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
|
||||
"BRIDGE_HOST_IP": (CONFIG_SECTION_BRIDGE, "host_ip"),
|
||||
"POLL_INTERVAL": (CONFIG_SECTION_BRIDGE, "poll_interval"),
|
||||
"VERBOSE_HTTP_LOG": (CONFIG_SECTION_BRIDGE, "verbose_http_log"),
|
||||
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
|
||||
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
|
||||
}
|
||||
|
||||
|
||||
def _load_config_file(path: pathlib.Path):
|
||||
"""Lädt config.ini und setzt Keys in os.environ (nur wenn nicht bereits gesetzt)."""
|
||||
"""Loads config.ini and sets keys in os.environ (only if not already set)."""
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
|
||||
mapping = {
|
||||
"PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"),
|
||||
"MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"),
|
||||
"MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"),
|
||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
|
||||
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
|
||||
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
|
||||
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
|
||||
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
|
||||
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
|
||||
}
|
||||
for env_key, (section, option) in mapping.items():
|
||||
for env_key, (section, option) in CONFIG_ENV_MAPPING.items():
|
||||
if env_key not in os.environ:
|
||||
try:
|
||||
val = cfg.get(section, option)
|
||||
@@ -112,8 +124,9 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
||||
}
|
||||
cfg[CONFIG_SECTION_PRINT] = {
|
||||
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
|
||||
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
|
||||
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
|
||||
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
|
||||
"vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"),
|
||||
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
|
||||
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
|
||||
}
|
||||
cfg[CONFIG_SECTION_BRIDGE] = {
|
||||
@@ -121,12 +134,12 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
||||
}
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
f.write("# KX-Bridge Konfigurationsdatei\n")
|
||||
f.write("# Automatisch migriert aus .env\n\n")
|
||||
f.write("# Automatically migrated from .env\n\n")
|
||||
cfg.write(f)
|
||||
|
||||
|
||||
def find_config_path() -> pathlib.Path:
|
||||
"""Gibt den Pfad zur config.ini zurück (auch wenn sie noch nicht existiert)."""
|
||||
"""Returns the path to config.ini (even if it does not exist yet)."""
|
||||
for base in (_BASE, _BASE.parent):
|
||||
config_dir = base / "config"
|
||||
if config_dir.is_dir():
|
||||
@@ -142,7 +155,7 @@ _env_path = _find_env_file()
|
||||
if _config_path:
|
||||
_load_config_file(_config_path)
|
||||
elif _env_path:
|
||||
# Kein config.ini vorhanden → aus .env migrieren
|
||||
# No config.ini present -> migrate from .env
|
||||
_target = find_config_path()
|
||||
migrate_env_to_config(_env_path, _target)
|
||||
_load_config_file(_target)
|
||||
@@ -150,13 +163,13 @@ elif _env_path:
|
||||
|
||||
|
||||
def list_printers() -> list[dict]:
|
||||
"""Liest alle [printer_N]-Sektionen aus config.ini.
|
||||
"""Reads all [printer_N] sections from config.ini.
|
||||
|
||||
Jede Sektion kann folgende Keys haben:
|
||||
Each section may contain the following keys:
|
||||
name, printer_ip, mqtt_port, username, password, mode_id, device_id,
|
||||
bridge_url, default_ams_slot, auto_leveling
|
||||
|
||||
Gibt eine leere Liste zurück wenn keine [printer_N]-Sektionen vorhanden sind
|
||||
Returns an empty list when no [printer_N] sections exist
|
||||
(Single-Printer-Betrieb via [connection]).
|
||||
"""
|
||||
path = _find_config_file()
|
||||
@@ -182,25 +195,43 @@ def list_printers() -> list[dict]:
|
||||
return printers
|
||||
|
||||
|
||||
def list_filament_profiles() -> dict[int, dict]:
|
||||
"""Liest die [filament_profiles]-Sektion aus config.ini.
|
||||
def _filament_section(printer_id: Optional[str] = None) -> str:
|
||||
"""Section name holding a printer's filament-profile mapping.
|
||||
|
||||
Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird
|
||||
aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt
|
||||
(als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit
|
||||
derselben filament_id wie 'OGFL99', d.h. die ID ist nicht eindeutig):
|
||||
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]:
|
||||
"""Reads the [filament_profiles] section from 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 per AMS slot - the primary selector is (vendor, name); the `id` is
|
||||
looked up from orca_filaments.json on save and carried along
|
||||
(as a hint for OrcaSlicer; the Orca data model has ~136 profiles sharing
|
||||
the same filament_id like 'OGFL99', i.e. the ID is not unique):
|
||||
|
||||
[filament_profiles]
|
||||
slot_0_vendor = Polymaker
|
||||
slot_0_name = PolyTerra PLA
|
||||
slot_0_id = OGFL01
|
||||
|
||||
Gibt einen Dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}
|
||||
zurück. Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping
|
||||
(per filament_type) in der Bridge bleibt dann aktiv.
|
||||
Returns a dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}.
|
||||
Empty/missing slots are NOT included - the default mapping
|
||||
(per filament_type) in the bridge then stays active.
|
||||
|
||||
Backwards-Kompat: alte Configs mit nur (vendor, id) bleiben lesbar; `name`
|
||||
fehlt dann und der Aufrufer kann optional aus der orca_filaments.json
|
||||
Backwards compat: old configs with only (vendor, id) stay readable; `name`
|
||||
is then missing and the caller can optionally resolve it from orca_filaments.json
|
||||
rekonstruieren.
|
||||
"""
|
||||
path = _find_config_file()
|
||||
@@ -208,11 +239,14 @@ def list_filament_profiles() -> dict[int, dict]:
|
||||
return {}
|
||||
cfg = configparser.ConfigParser()
|
||||
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 {}
|
||||
result: dict[int, dict] = {}
|
||||
for key, value in cfg.items("filament_profiles"):
|
||||
# Erwartet: slot_<idx>_id oder slot_<idx>_vendor oder slot_<idx>_name
|
||||
for key, value in cfg.items(section):
|
||||
# Expects: slot_<idx>_id or slot_<idx>_vendor or slot_<idx>_name
|
||||
if not key.startswith("slot_"):
|
||||
continue
|
||||
parts = key.split("_", 2)
|
||||
@@ -231,74 +265,173 @@ def list_filament_profiles() -> dict[int, dict]:
|
||||
return result
|
||||
|
||||
|
||||
def save_filament_profiles(profiles: dict[int, dict]) -> bool:
|
||||
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
|
||||
Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
|
||||
def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] = None) -> bool:
|
||||
"""Writes the given slot profiles into the [filament_profiles]
|
||||
section of config.ini. Existing entries are completely replaced.
|
||||
|
||||
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}}
|
||||
Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint).
|
||||
At least vendor+name must be set; id is 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()
|
||||
if not path:
|
||||
return False
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
# visible_vendors (Issue #41) ist kein Slot-Mapping — beim Ersetzen der
|
||||
# Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren.
|
||||
section = _filament_section(printer_id)
|
||||
# visible_vendors (Issue #41) is not a slot mapping - preserve it when
|
||||
# replacing the section, otherwise the vendor filter is lost on slot save.
|
||||
# First save of a per-printer section inherits the legacy global filter.
|
||||
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")
|
||||
if cfg.has_section("filament_profiles"):
|
||||
cfg.remove_section("filament_profiles")
|
||||
if cfg.has_section(section):
|
||||
cfg.remove_section(section)
|
||||
if profiles or preserved_vendors:
|
||||
cfg["filament_profiles"] = {}
|
||||
cfg[section] = {}
|
||||
if preserved_vendors:
|
||||
cfg["filament_profiles"]["visible_vendors"] = preserved_vendors
|
||||
cfg[section]["visible_vendors"] = preserved_vendors
|
||||
for slot_idx in sorted(profiles.keys()):
|
||||
entry = profiles[slot_idx] or {}
|
||||
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"):
|
||||
cfg["filament_profiles"][f"slot_{slot_idx}_name"] = entry["name"]
|
||||
cfg[section][f"slot_{slot_idx}_name"] = entry["name"]
|
||||
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:
|
||||
cfg.write(f)
|
||||
return True
|
||||
|
||||
|
||||
def list_visible_vendors() -> list[str]:
|
||||
"""Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini.
|
||||
def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]:
|
||||
"""Reads [filament_profiles] visible_vendors (comma-separated) from config.ini.
|
||||
|
||||
Vendor-Sichtbarkeitsfilter für das Slot-Profil-Dropdown (Issue #41 Option A).
|
||||
Leere Liste = keine Einschränkung (rückwärtskompatibel: alle Vendoren).
|
||||
Vendor visibility filter for the slot profile dropdown (Issue #41 option A).
|
||||
Empty list = no restriction (backwards compatible: all vendors).
|
||||
|
||||
With ``printer_id`` set, reads the per-printer section and falls back to the
|
||||
legacy global ``[filament_profiles]`` filter.
|
||||
"""
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return []
|
||||
cfg = configparser.ConfigParser()
|
||||
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 []
|
||||
raw = cfg.get("filament_profiles", "visible_vendors")
|
||||
raw = cfg.get(section, "visible_vendors")
|
||||
return [v.strip() for v in raw.split(",") if v.strip()]
|
||||
|
||||
|
||||
def save_visible_vendors(vendors: list[str]) -> bool:
|
||||
"""Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings
|
||||
(slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder."""
|
||||
def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -> bool:
|
||||
"""Writes visible_vendors into [filament_profiles] without touching the
|
||||
(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()
|
||||
if not path:
|
||||
return False
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
if not cfg.has_section("filament_profiles"):
|
||||
cfg.add_section("filament_profiles")
|
||||
section = _filament_section(printer_id)
|
||||
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()]
|
||||
if clean:
|
||||
cfg["filament_profiles"]["visible_vendors"] = ", ".join(clean)
|
||||
elif cfg.has_option("filament_profiles", "visible_vendors"):
|
||||
cfg.remove_option("filament_profiles", "visible_vendors")
|
||||
cfg[section]["visible_vendors"] = ", ".join(clean)
|
||||
elif cfg.has_option(section, "visible_vendors"):
|
||||
cfg.remove_option(section, "visible_vendors")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
cfg.write(f)
|
||||
return True
|
||||
|
||||
|
||||
def _spoolman_map_section(printer_id: Optional[str] = None) -> str:
|
||||
"""Section name holding a printer's AMS-slot → Spoolman-spool map.
|
||||
|
||||
Multi-printer (one bridge, N printers): each printer keeps its map in its
|
||||
own ``[spoolman_<id>]`` section so two AMS units cannot overwrite each
|
||||
other's mapping. ``printer_id is None`` (single-printer / legacy callers)
|
||||
uses the original ``[spoolman] slot_spools`` key — full backward
|
||||
compatibility. The global ``[spoolman]`` section keeps ``server`` /
|
||||
``sync_rate`` regardless.
|
||||
"""
|
||||
pid = str(printer_id).strip() if printer_id is not None else ""
|
||||
if pid and pid != "0":
|
||||
return f"{CONFIG_SECTION_SPOOLMAN}_{pid}"
|
||||
return CONFIG_SECTION_SPOOLMAN
|
||||
|
||||
|
||||
def _parse_slot_spools(raw: str) -> dict[int, int]:
|
||||
"""Parse ``"0:42,1:17"`` → ``{0: 42, 1: 17}`` (positive spool ids only)."""
|
||||
result: dict[int, int] = {}
|
||||
for pair in (raw or "").split(","):
|
||||
pair = pair.strip()
|
||||
if ":" not in pair:
|
||||
continue
|
||||
k, _, v = pair.partition(":")
|
||||
k, v = k.strip(), v.strip()
|
||||
if k.isdigit() and v.lstrip("-").isdigit() and int(v) > 0:
|
||||
result[int(k)] = int(v)
|
||||
return result
|
||||
|
||||
|
||||
def list_spool_map(printer_id: Optional[str] = None) -> dict[int, int]:
|
||||
"""Read the AMS-slot → Spoolman-spool-id map from config.ini.
|
||||
|
||||
With ``printer_id`` set, reads the per-printer ``[spoolman_<id>]
|
||||
slot_spools`` key and falls back to the legacy global ``[spoolman]
|
||||
slot_spools`` while that printer has no own section yet. Returns
|
||||
``{slot_index: spool_id}`` (only positive ids).
|
||||
"""
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return {}
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
section = _spoolman_map_section(printer_id)
|
||||
if cfg.has_option(section, "slot_spools"):
|
||||
return _parse_slot_spools(cfg.get(section, "slot_spools", fallback=""))
|
||||
if cfg.has_option(CONFIG_SECTION_SPOOLMAN, "slot_spools"): # legacy global fallback
|
||||
return _parse_slot_spools(cfg.get(CONFIG_SECTION_SPOOLMAN, "slot_spools", fallback=""))
|
||||
return {}
|
||||
|
||||
|
||||
def save_spool_map(slot_spools: dict[int, int], printer_id: Optional[str] = None) -> bool:
|
||||
"""Persist the AMS-slot → Spoolman-spool-id map to config.ini.
|
||||
|
||||
With ``printer_id`` set, writes only the per-printer ``[spoolman_<id>]``
|
||||
section so other printers and the global ``[spoolman]`` server config stay
|
||||
untouched. An empty map clears the key.
|
||||
"""
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return False
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
section = _spoolman_map_section(printer_id)
|
||||
clean = {int(k): int(v) for k, v in (slot_spools or {}).items() if int(v) > 0}
|
||||
if clean:
|
||||
if not cfg.has_section(section):
|
||||
cfg.add_section(section)
|
||||
cfg[section]["slot_spools"] = ",".join(f"{k}:{v}" for k, v in sorted(clean.items()))
|
||||
elif cfg.has_option(section, "slot_spools"):
|
||||
cfg.remove_option(section, "slot_spools")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
cfg.write(f)
|
||||
return True
|
||||
@@ -308,7 +441,7 @@ def get(key: str, default: str = "") -> str:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
# Häufig verwendete Shortcuts
|
||||
# Frequently used shortcuts
|
||||
PRINTER_IP = get("PRINTER_IP", "")
|
||||
MQTT_PORT = int(get("MQTT_PORT", "9883"))
|
||||
USERNAME = get("MQTT_USERNAME", "")
|
||||
@@ -316,9 +449,13 @@ PASSWORD = get("MQTT_PASSWORD", "")
|
||||
MODE_ID = get("MODE_ID", "")
|
||||
DEVICE_ID = get("DEVICE_ID", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING","1"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0"))
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
|
||||
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
|
||||
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
|
||||
SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "")
|
||||
SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0"))
|
||||
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")
|
||||
POLL_INTERVAL = int(get("POLL_INTERVAL", "3"))
|
||||
VERBOSE_HTTP_LOG = int(get("VERBOSE_HTTP_LOG", "0"))
|
||||
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
- ./.env:/app/.env:ro
|
||||
ports:
|
||||
- "7125-7130:7125-7130"
|
||||
# environment:
|
||||
# - BRIDGE_HOST_IP=192.168.1.100 # LAN-IP des Docker-Hosts (für korrekte Log-Anzeige)
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
env_loader.py – lädt Verbindungsparameter aus .env (Repo-Root oder Arbeitsverzeichnis).
|
||||
Umgebungsvariablen haben Vorrang vor .env-Werten.
|
||||
env_loader.py - loads connection parameters from .env (repo root or working directory).
|
||||
Environment variables take precedence over .env values.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -39,7 +39,7 @@ def get(key: str, default: str = "") -> str:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
# Häufig verwendete Shortcuts
|
||||
# Frequently used shortcuts
|
||||
PRINTER_IP = get("PRINTER_IP", "")
|
||||
MQTT_PORT = int(get("MQTT_PORT", "9883"))
|
||||
USERNAME = get("MQTT_USERNAME", "")
|
||||
@@ -47,7 +47,9 @@ PASSWORD = get("MQTT_PASSWORD", "")
|
||||
MODE_ID = get("MODE_ID", "")
|
||||
DEVICE_ID = get("DEVICE_ID", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
|
||||
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
|
||||
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
|
||||
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")
|
||||
|
||||
116
kobrax_client.py
116
kobrax_client.py
@@ -1,10 +1,10 @@
|
||||
"""
|
||||
kobrax_client.py – Anycubic Kobra X LAN-MQTT-Client
|
||||
|
||||
Protokoll vollständig rekonstruiert via Sniffer 2026-04-17 (953 Nachrichten).
|
||||
Protocol fully reconstructed via sniffer 2026-04-17 (953 messages).
|
||||
|
||||
Voraussetzungen:
|
||||
- /tmp/anycubic_slicer.crt und .key (aus cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0)
|
||||
- /tmp/anycubic_slicer.crt and .key (from cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0)
|
||||
- Drucker im LAN-Modus erreichbar auf Port 9883
|
||||
|
||||
Verwendung:
|
||||
@@ -121,9 +121,9 @@ class KobraXClient:
|
||||
self._buf = b""
|
||||
self._pid = 1
|
||||
self._lock = threading.Lock()
|
||||
# Generations-Marker: wird bei jedem Socket-Swap/Close erhöht, damit der
|
||||
# Reader-Thread erkennt wenn _reconnect/_do_connect den Socket unter ihm
|
||||
# ersetzt hat (Issue #53). Schützt gegen recv auf einem stale fd.
|
||||
# Generation marker: incremented on every socket swap/close so the
|
||||
# reader thread notices when _reconnect/_do_connect swapped the socket
|
||||
# underneath it (Issue #53). Protects against recv on a stale fd.
|
||||
self._sock_gen = 0
|
||||
self._running = False
|
||||
|
||||
@@ -162,9 +162,9 @@ class KobraXClient:
|
||||
if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE):
|
||||
raise FileNotFoundError(
|
||||
f"TLS-Zertifikate fehlen: anycubic_slicer.crt + anycubic_slicer.key "
|
||||
f"müssen neben der kx-bridge Binary liegen ({_SCRIPT_DIR}/). "
|
||||
f"Lade anycubic-certs.zip vom Gitea-Release herunter und entpacke "
|
||||
f"die Dateien dorthin."
|
||||
f"must sit next to the kx-bridge binary ({_SCRIPT_DIR}/). "
|
||||
f"Download anycubic-certs.zip from the Gitea release and extract "
|
||||
f"the files there."
|
||||
)
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
@@ -172,9 +172,9 @@ class KobraXClient:
|
||||
ctx.set_ciphers("DEFAULT:@SECLEVEL=0")
|
||||
ctx.load_cert_chain(CERT_FILE, KEY_FILE)
|
||||
|
||||
# Socket als lokale Variable aufbauen — der Handshake (Connect + CONNACK)
|
||||
# läuft OHNE gehaltenes Lock, damit ein langsamer Connect die Sender nicht
|
||||
# einfriert. Erst der fertige Socket wird unter Lock eingeschwenkt (#53).
|
||||
# Build the socket as a local variable - the handshake (connect + CONNACK)
|
||||
# runs WITHOUT holding the lock so a slow connect does not freeze
|
||||
# senders. Only the finished socket is swapped in under the lock (#53).
|
||||
_ai = socket.getaddrinfo(self.host, self.port, socket.AF_INET, socket.SOCK_STREAM)
|
||||
raw = socket.create_connection(_ai[0][4], timeout=5)
|
||||
new_sock = ctx.wrap_socket(raw)
|
||||
@@ -196,7 +196,7 @@ class KobraXClient:
|
||||
self._sock = new_sock
|
||||
self._sock_gen += 1
|
||||
self._buf = b""
|
||||
self._subscribe(self._sub_topic()) # nimmt das Lock selbst — nicht verschachteln
|
||||
self._subscribe(self._sub_topic()) # takes the lock itself - do not nest
|
||||
log.debug("MQTT connected to %s:%s", self.host, self.port)
|
||||
|
||||
def connect(self):
|
||||
@@ -206,10 +206,10 @@ class KobraXClient:
|
||||
time.sleep(0.3)
|
||||
|
||||
def _ensure_reader(self):
|
||||
"""Stellt sicher dass der Reader-Thread lebt. Wenn der Reader nach einer
|
||||
früheren disconnect/reconnect-Sequenz oder einem unbehandelten Fehler
|
||||
gestorben ist, würden empfangene Replies sonst nie ankommen — publish()
|
||||
würde dann zwar senden, aber auf Antworten ewig warten."""
|
||||
"""Ensures the reader thread is alive. If the reader died after a
|
||||
previous disconnect/reconnect sequence or an unhandled error,
|
||||
received replies would never arrive - publish()
|
||||
would still send but wait for replies forever."""
|
||||
if not self._running:
|
||||
return # gewollter disconnect
|
||||
t = getattr(self, "_reader_thread", None)
|
||||
@@ -232,13 +232,13 @@ class KobraXClient:
|
||||
self._sock_gen += 1
|
||||
|
||||
def _reconnect(self):
|
||||
"""Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder
|
||||
antwortet oder disconnect() gerufen wurde. Backoff cappt bei 60 s. Die
|
||||
ersten 5 Versuche loggen als WARNING (akute Verbindungsstörung), danach
|
||||
nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht
|
||||
"""Persistent reconnect: keeps retrying forever until the printer is
|
||||
responds or disconnect() was called. Backoff caps at 60 s. The
|
||||
first 5 attempts log as WARNING (acute connection issue), afterwards
|
||||
only DEBUG to avoid log spam during long printer outages (e.g. switched
|
||||
ausgeschaltet) zu vermeiden."""
|
||||
log.warning("Verbindung verloren – reconnect…")
|
||||
# Close + Invalidierung unter Lock, damit kein Sender mitten im sendall
|
||||
log.warning("Connection lost - reconnecting...")
|
||||
# Close + invalidation under the lock so no sender is mid-sendall
|
||||
# auf den gerade geschlossenen Socket trifft (Issue #53).
|
||||
with self._lock:
|
||||
try:
|
||||
@@ -254,18 +254,18 @@ class KobraXClient:
|
||||
delay = delays[min(attempt, len(delays) - 1)]
|
||||
try:
|
||||
self._do_connect()
|
||||
log.info("Reconnect erfolgreich (nach %d Versuchen)", attempt + 1)
|
||||
log.info("Reconnect successful (after %d attempts)", attempt + 1)
|
||||
return True
|
||||
except Exception as e:
|
||||
attempt += 1
|
||||
lvl = log.warning if attempt <= 5 else log.debug
|
||||
lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay)
|
||||
# Geteiltes Sleep damit disconnect() den Loop schneller bricht.
|
||||
# Split sleep so disconnect() breaks the loop faster.
|
||||
slept = 0.0
|
||||
while slept < delay and self._running:
|
||||
time.sleep(min(0.5, delay - slept))
|
||||
slept += 0.5
|
||||
return False # nur wenn disconnect() gerufen wurde
|
||||
return False # only when disconnect() was called
|
||||
|
||||
def _subscribe(self, topic: str):
|
||||
with self._lock:
|
||||
@@ -290,15 +290,15 @@ class KobraXClient:
|
||||
ping_ok = True
|
||||
except Exception:
|
||||
ping_ok = False
|
||||
# _reconnect() AUSSERHALB des Locks aufrufen — es nimmt das Lock
|
||||
# selbst, und threading.Lock ist nicht reentrant (sonst Deadlock).
|
||||
# Call _reconnect() OUTSIDE the lock - it takes the lock
|
||||
# itself, and threading.Lock is not reentrant (deadlock otherwise).
|
||||
if not ping_ok:
|
||||
if self._running and not self._reconnect():
|
||||
break
|
||||
last_ping = time.time()
|
||||
# Aktuellen Socket + Generation unter Lock greifen, damit ein
|
||||
# paralleler _reconnect/_do_connect-Swap uns nicht auf einem stale
|
||||
# fd pollen lässt (Issue #53).
|
||||
# Grab the current socket + generation under the lock so a
|
||||
# parallel _reconnect/_do_connect swap does not leave us polling
|
||||
# a stale fd (Issue #53).
|
||||
with self._lock:
|
||||
sock = self._sock
|
||||
gen = self._sock_gen
|
||||
@@ -306,37 +306,37 @@ class KobraXClient:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
|
||||
# Idle-Wartezeit OHNE Lock — select probt nur die Bereitschaft, so
|
||||
# blockiert der Reader während Leerlauf nie das gemeinsame Lock.
|
||||
# Idle wait WITHOUT the lock - select only probes readiness, so
|
||||
# the reader never blocks the shared lock while idle.
|
||||
try:
|
||||
ready, _, _ = select.select([sock], [], [], 0.2)
|
||||
except (OSError, ValueError):
|
||||
# fd geschlossen/ungültig (Reconnect oder Disconnect mitten im select)
|
||||
# fd closed/invalid (reconnect or disconnect mid-select)
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
if not ready:
|
||||
continue # Leerlauf, kein Lock gehalten
|
||||
continue # idle, no lock held
|
||||
|
||||
# Daten liegen an: Lock kurz greifen für das eine recv, serialisiert
|
||||
# gegen alle sendall-Caller. recv blockiert nicht lange (select sagte
|
||||
# ready, Socket-Timeout ist 0.2s).
|
||||
# Data pending: briefly take the lock for the single recv, serialized
|
||||
# against all sendall callers. recv does not block long (select said
|
||||
# ready, socket timeout is 0.2s).
|
||||
try:
|
||||
with self._lock:
|
||||
# Socket könnte zwischen select und hier ersetzt worden sein.
|
||||
# The socket could have been swapped between select and here.
|
||||
if self._sock_gen != gen or self._sock is not sock:
|
||||
continue
|
||||
data = sock.recv(65536)
|
||||
if not data:
|
||||
# Windows SSL kann kurzzeitig b"" liefern ohne echten EOF
|
||||
# Windows SSL can briefly return b"" without a real EOF
|
||||
_empty_count += 1
|
||||
if _empty_count >= 5:
|
||||
raise ConnectionResetError("EOF")
|
||||
continue
|
||||
_empty_count = 0
|
||||
self._buf += data
|
||||
self._drain() # außerhalb des Locks — Dispatch/event.set() bleibt prompt
|
||||
self._drain() # outside the lock - dispatch/event.set() stays prompt
|
||||
except ssl.SSLWantReadError:
|
||||
continue
|
||||
except socket.timeout:
|
||||
@@ -445,8 +445,8 @@ class KobraXClient:
|
||||
# -- Publish + request/response ------------------------------------------
|
||||
|
||||
def publish(self, msg_type: str, action: str, data=None, timeout: float = 5.0) -> dict | None:
|
||||
# Falls Reader-Thread aus historischen Gründen tot ist, wiederbeleben —
|
||||
# sonst würden Replies nie ankommen und event.wait() läuft ins Timeout.
|
||||
# If the reader thread is dead for historical reasons, revive it -
|
||||
# otherwise replies would never arrive and event.wait() would time out.
|
||||
self._ensure_reader()
|
||||
msgid = str(uuid.uuid4())
|
||||
payload = json.dumps({
|
||||
@@ -471,7 +471,7 @@ class KobraXClient:
|
||||
report_registered = True
|
||||
|
||||
topic = self._pub_topic(msg_type)
|
||||
# Status-Poll-TX (query/getInfo) ist reines Rauschen (alle paar Sekunden) →
|
||||
# Status poll TX (query/getInfo) is pure noise (every few seconds) ->
|
||||
# auf DEBUG. Aktions-TX (start/set/control/move/…) bleibt INFO sichtbar.
|
||||
_tx_level = logging.DEBUG if action in ("query", "getInfo") else logging.INFO
|
||||
log.log(_tx_level, "TX %-25s action=%-12s data=%s",
|
||||
@@ -531,8 +531,8 @@ class KobraXClient:
|
||||
self._sock.sendall(_build_publish(topic, payload))
|
||||
except Exception as e:
|
||||
log.error("web send error: %s, reconnecting…", e)
|
||||
# Reconnect triggern (analog zu publish()); ohne Retry weil
|
||||
# fire-and-forget — der nächste Aufruf wird auf den frischen Socket
|
||||
# Trigger a reconnect (like publish()); no retry because it is
|
||||
# fire-and-forget - the next call will hit the fresh socket
|
||||
# treffen.
|
||||
try:
|
||||
self._reconnect()
|
||||
@@ -579,13 +579,13 @@ class KobraXClient:
|
||||
# -- Part-Skip ("Exclude Object") ---------------------------------------
|
||||
|
||||
def query_skip_objects(self) -> dict | None:
|
||||
"""Fragt den Drucker nach der aktuellen Objekt-/Skip-Liste."""
|
||||
"""Asks the printer for the current object/skip list."""
|
||||
return self.publish("skip", "query_obj")
|
||||
|
||||
def skip_objects(self, names: list[str]) -> dict | None:
|
||||
"""Überspringt die genannten Objekte – auch mid-print möglich.
|
||||
"""Skips the named objects - also possible mid-print.
|
||||
|
||||
Namen entsprechen den EXCLUDE_OBJECT_DEFINE NAME=… Einträgen
|
||||
Names correspond to the EXCLUDE_OBJECT_DEFINE NAME=... entries
|
||||
im GCode-Header bzw. file_details.objects_skip_parts.
|
||||
"""
|
||||
return self.publish("skip", "start", {"objects_skip_parts": list(names)})
|
||||
@@ -653,14 +653,14 @@ class KobraXClient:
|
||||
f"Connection: close\r\n\r\n"
|
||||
).encode()
|
||||
|
||||
# Connect-Timeout kurz (LAN). Während sendall() darf der Socket so
|
||||
# lange brauchen wie nötig — bei großen Dateien (>100 MB) und
|
||||
# langsamerem WLAN am Drucker dauert das Schieben sonst >30 s und
|
||||
# würde den Connect-Timeout fälschlich auslösen. Read-Timeout danach
|
||||
# generös (Drucker verarbeitet die Datei bevor er antwortet).
|
||||
# Short connect timeout (LAN). During sendall() the socket may take
|
||||
# as long as needed - with large files (>100 MB) and slower WiFi
|
||||
# at the printer, pushing otherwise takes >30 s and would falsely
|
||||
# trip the connect timeout. The read timeout afterwards is generous
|
||||
# (the printer processes the file before replying).
|
||||
_ai = socket.getaddrinfo(self.host, 18910, socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock = socket.create_connection(_ai[0][4], timeout=10)
|
||||
sock.settimeout(None) # blocking während Send
|
||||
sock.settimeout(None) # blocking during send
|
||||
sock.sendall(headers + body)
|
||||
sock.settimeout(180)
|
||||
response = b""
|
||||
@@ -717,7 +717,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
|
||||
parser.add_argument("--device-id", default=env_loader.DEVICE_ID)
|
||||
parser.add_argument("--monitor", action="store_true",
|
||||
help="Dauerhaft mithören und alle Reports ausgeben")
|
||||
help="Listen continuously and print all reports")
|
||||
args = parser.parse_args()
|
||||
|
||||
client = KobraXClient(
|
||||
@@ -741,7 +741,7 @@ if __name__ == "__main__":
|
||||
|
||||
client.callbacks["*"] = on_msg
|
||||
client.connect()
|
||||
print("[kobrax] Monitor-Modus aktiv (Ctrl-C zum Beenden)")
|
||||
print("[kobrax] Monitor mode active (Ctrl-C to stop)")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
@@ -755,7 +755,7 @@ if __name__ == "__main__":
|
||||
info = client.query_info()
|
||||
if info:
|
||||
d = info.get("data", {})
|
||||
print(f" Drucker: {d.get('printerName')} FW {d.get('version')}")
|
||||
print(f" Printer: {d.get('printerName')} FW {d.get('version')}")
|
||||
print(f" Status: {d.get('state')}")
|
||||
t = d.get("temp", {})
|
||||
print(f" Nozzle: {t.get('curr_nozzle_temp')}°C → {t.get('target_nozzle_temp')}°C")
|
||||
@@ -764,6 +764,6 @@ if __name__ == "__main__":
|
||||
print(f" Upload: {urls.get('fileUploadurl')}")
|
||||
print(f" Kamera: {urls.get('rtspUrl')}")
|
||||
else:
|
||||
print(" Keine Antwort")
|
||||
print(" No response")
|
||||
|
||||
client.disconnect()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
"""OrcaSlicer Filament-Profil Parser.
|
||||
|
||||
Geteilt zwischen dem Generator (tools/gen_orca_filament_list.py) und dem
|
||||
Shared between the generator (tools/gen_orca_filament_list.py) and the
|
||||
Custom-Profile-Import-Endpoint (bridge/kobrax_moonraker_bridge.py).
|
||||
|
||||
Liest Orca-Filament-JSON-Dateien (System- oder User-Profile) und gibt
|
||||
sie als normalisierte Liste mit (id, name, vendor, type, color) zurück.
|
||||
Reads Orca filament JSON files (system or user profiles) and returns
|
||||
them as a normalized list with (id, name, vendor, type, color).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,8 +13,8 @@ import re
|
||||
|
||||
|
||||
def first_str(value, default: str = "") -> str:
|
||||
"""Orca-Profile speichern manche Felder als ['wert']. Liefert erstes
|
||||
Element als String."""
|
||||
"""Orca profiles store some fields as ['value']. Returns the first
|
||||
element as a string."""
|
||||
if isinstance(value, list):
|
||||
return str(value[0]) if value else default
|
||||
if isinstance(value, str):
|
||||
@@ -37,34 +37,34 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
path_vendor: str | None = None,
|
||||
source_path: str = "",
|
||||
system_index: list | None = None) -> dict | None:
|
||||
"""Parsed ein einzelnes Orca-Filament-Profil zum Bridge-Schema.
|
||||
"""Parses a single Orca filament profile into the bridge schema.
|
||||
|
||||
`by_name` ist optional ein {name: [profile, …]}-Index für Inherits-Resolve
|
||||
aus dem rohen Source-Tree (Generator). Bei Single-File-Import (User-Datei
|
||||
aus OrcaSlicer-User-Dir) reichen wir stattdessen `system_index` rein —
|
||||
die fertige System-Profile-Liste aus orca_filaments.json. Damit können
|
||||
wir filament_id/vendor/type/color über die `inherits`-Kette aus dem
|
||||
System-Parent ableiten, auch wenn das User-Profil diese Felder nicht
|
||||
selbst setzt (typisch: User-Override-Profile haben nur Tweaks).
|
||||
`by_name` is optionally a {name: [profile, ...]} index for inherits resolution
|
||||
from the raw source tree (generator). For single-file imports (user file
|
||||
from the OrcaSlicer user dir) we pass `system_index` instead -
|
||||
the finished system profile list from orca_filaments.json. This lets
|
||||
us derive filament_id/vendor/type/color via the `inherits` chain from
|
||||
the system parent even when the user profile does not set these
|
||||
fields itself (typically: user override profiles only contain tweaks).
|
||||
|
||||
Liefert {id, name, vendor, type, color} oder None wenn das Profil
|
||||
keine filament_id hat (z.B. abstrakte @base-Templates).
|
||||
Returns {id, name, vendor, type, color} or None when the profile
|
||||
has no filament_id (e.g. abstract @base templates).
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
# User-Profile aus dem OrcaSlicer-User-Dir setzen oft KEIN "type"-Feld —
|
||||
# das kommt vom System-Parent. Wir akzeptieren das wenn entweder "type"
|
||||
# explizit "filament" ist ODER ein "inherits" auf ein anderes Profil zeigt.
|
||||
# User profiles from the OrcaSlicer user dir often set NO "type" field -
|
||||
# it comes from the system parent. We accept that when either "type"
|
||||
# is explicitly "filament" OR an "inherits" points to another profile.
|
||||
if data.get("type") not in (None, "filament") and not data.get("inherits"):
|
||||
return None
|
||||
if data.get("type") == "filament" and data.get("inherits") is None and not data.get("filament_id"):
|
||||
# type=filament aber kein parent + keine ID → wertloses Stub
|
||||
# type=filament but no parent + no ID -> worthless stub
|
||||
return None
|
||||
inst = data.get("instantiation", "true")
|
||||
if isinstance(inst, str) and inst.lower() == "false":
|
||||
return None
|
||||
|
||||
# Build system-name-Index für den fallback-Lookup wenn system_index gesetzt.
|
||||
# Build the system name index for the fallback lookup when system_index is set.
|
||||
sys_by_name: dict[str, dict] = {}
|
||||
if system_index:
|
||||
for p in system_index:
|
||||
@@ -92,7 +92,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
return None
|
||||
|
||||
def _resolve_via_system_index(key: str):
|
||||
"""Inherits-Kette über system_index (clean_name-Match)."""
|
||||
"""Inherits chain via system_index (clean_name match)."""
|
||||
parent_raw = data.get("inherits")
|
||||
if not parent_raw or not sys_by_name:
|
||||
return None
|
||||
@@ -100,7 +100,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
sys_p = sys_by_name.get(parent_clean)
|
||||
if not sys_p:
|
||||
return None
|
||||
# System-JSON benutzt schon das normalisierte Schema
|
||||
# The system JSON already uses the normalized schema
|
||||
mapping = {
|
||||
"filament_id": "id",
|
||||
"filament_vendor": "vendor",
|
||||
@@ -136,10 +136,10 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
|
||||
def parse_profile_bytes(blob: bytes, source_name: str = "",
|
||||
system_index: list | None = None) -> dict | None:
|
||||
"""Liest ein einzelnes Profil aus JSON-Bytes. Für File-Upload-Pfad.
|
||||
`system_index` ist optional die fertige Liste aus orca_filaments.json —
|
||||
wird für die Inherits-Resolve von User-Profilen genutzt die das volle
|
||||
Schema vom System-Parent erben."""
|
||||
"""Reads a single profile from JSON bytes. For the file upload path.
|
||||
`system_index` is optionally the finished list from orca_filaments.json -
|
||||
used for the inherits resolution of user profiles that do not carry the full
|
||||
schema from the system parent."""
|
||||
try:
|
||||
data = json.loads(blob.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
|
||||
@@ -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
|
||||
64
start.sh
64
start.sh
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# start.sh – KX-Bridge starten (baut Docker-Image automatisch wenn nötig)
|
||||
# start.sh – KX-Bridge starten (zieht das fertige Image aus der Registry)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
IMAGE_BASE="gitea.it-drui.de/viewit/kx-bridge"
|
||||
|
||||
# .env anlegen falls nicht vorhanden
|
||||
if [[ ! -f .env ]]; then
|
||||
if [[ -f .env.example ]]; then
|
||||
@@ -30,38 +32,38 @@ if ! docker info > /dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prüfen ob Build nötig ist
|
||||
NEEDS_BUILD=0
|
||||
if ! docker image inspect kx-bridge:latest > /dev/null 2>&1; then
|
||||
echo "[start] Image nicht vorhanden – baue kx-bridge:latest ..."
|
||||
NEEDS_BUILD=1
|
||||
# Release-Kanal abfragen
|
||||
CHANNEL=""
|
||||
if [[ "${1:-}" == "stable" || "${1:-}" == "nightly" ]]; then
|
||||
CHANNEL="$1"
|
||||
else
|
||||
# Image-Erstellungszeit in Unix-Sekunden
|
||||
IMAGE_TS=$(docker inspect --format='{{.Created}}' kx-bridge:latest \
|
||||
| python3 -c "import sys,datetime; s=sys.stdin.read().strip(); \
|
||||
s=s[:26].rstrip('Z').replace('T',' '); \
|
||||
print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0)
|
||||
|
||||
for f in Dockerfile \
|
||||
bridge/kobrax_moonraker_bridge.py \
|
||||
bridge/kobrax_client.py \
|
||||
bridge/env_loader.py \
|
||||
bridge/requirements.txt \
|
||||
bridge/anycubic_slicer.crt \
|
||||
bridge/anycubic_slicer.key; do
|
||||
if [[ -f "$f" ]]; then
|
||||
FILE_TS=$(python3 -c "import os; print(int(os.path.getmtime('$f')))" 2>/dev/null || echo 0)
|
||||
if [[ $FILE_TS -gt $IMAGE_TS ]]; then
|
||||
echo "[start] '$f' ist neuer als das Image – baue neu ..."
|
||||
NEEDS_BUILD=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo " Welchen Release-Kanal möchtest du starten?"
|
||||
echo " 1) stable (empfohlen)"
|
||||
echo " 2) nightly (getestete Vorabversion)"
|
||||
echo -n " Auswahl [1]: "
|
||||
read -r CHOICE
|
||||
case "$CHOICE" in
|
||||
2) CHANNEL="nightly" ;;
|
||||
*) CHANNEL="stable" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ $NEEDS_BUILD -eq 1 ]]; then
|
||||
docker build -t kx-bridge:latest .
|
||||
if [[ "$CHANNEL" == "nightly" ]]; then
|
||||
IMAGE_TAG="nightly"
|
||||
else
|
||||
IMAGE_TAG="latest"
|
||||
fi
|
||||
IMAGE="$IMAGE_BASE:$IMAGE_TAG"
|
||||
|
||||
echo "[start] Kanal: $CHANNEL → Image: $IMAGE"
|
||||
echo "[start] Ziehe aktuelles Image ..."
|
||||
docker pull "$IMAGE"
|
||||
|
||||
# docker-compose.yml auf den gewählten Kanal umschreiben (nur die image-Zeile)
|
||||
if [[ -f docker-compose.yml ]]; then
|
||||
sed -i.bak -E "s#^(\s*image:\s*).*#\1$IMAGE#" docker-compose.yml
|
||||
rm -f docker-compose.yml.bak
|
||||
fi
|
||||
|
||||
# Container starten
|
||||
@@ -70,7 +72,7 @@ docker-compose down 2>/dev/null || true
|
||||
docker-compose up -d
|
||||
|
||||
echo ""
|
||||
echo " ✓ KX-Bridge läuft"
|
||||
echo " ✓ KX-Bridge läuft ($CHANNEL)"
|
||||
echo " Web-UI : http://$(hostname -I | awk '{print $1}'):7125"
|
||||
echo " Logs : docker-compose logs -f"
|
||||
echo " Stop : docker-compose down"
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
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
|
||||
import sys, types, argparse, tempfile, 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"))
|
||||
# Flat repo layout (no bridge/ subfolder anymore) — point at the repo root.
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent))
|
||||
|
||||
# ── env_loader mocken (keine .env nötig) ──────────────────────────────────────
|
||||
env_mod = types.ModuleType("env_loader")
|
||||
@@ -41,6 +42,7 @@ def make_args(**overrides):
|
||||
device_id = "",
|
||||
host = "127.0.0.1",
|
||||
port = 7125,
|
||||
data_dir = tempfile.mkdtemp(prefix="kxtest-"),
|
||||
)
|
||||
for k, v in overrides.items():
|
||||
setattr(args, k, v)
|
||||
|
||||
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+"
|
||||
@@ -40,14 +40,14 @@ async def test_settings_get_returns_configured_values(client_configured):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_post_writes_env(client):
|
||||
"""POST /api/settings schreibt Werte in .env-Datei."""
|
||||
async def test_settings_post_writes_config_ini(client):
|
||||
"""POST /api/settings schreibt Werte in config.ini (Migration von .env, v0.9.x)."""
|
||||
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
|
||||
config_path = pathlib.Path(tmpdir) / "config.ini"
|
||||
bridge._find_config_path = lambda: config_path
|
||||
bridge._restart_bridge = lambda: None # POST triggers a restart — don't kill the test process
|
||||
|
||||
resp = await c.post("/api/settings", json={
|
||||
"printer_ip": "10.0.0.5",
|
||||
@@ -59,24 +59,28 @@ async def test_settings_post_writes_env(client):
|
||||
})
|
||||
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
|
||||
content = config_path.read_text()
|
||||
assert "printer_ip = 10.0.0.5" in content
|
||||
assert "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)."""
|
||||
"""POST darf unbekannte Sections/Optionen in config.ini nicht löschen (z.B. Spoolman-Server)."""
|
||||
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
|
||||
config_path = pathlib.Path(tmpdir) / "config.ini"
|
||||
config_path.write_text(
|
||||
"[spoolman]\nserver = http://192.168.1.50:7912\n\n"
|
||||
"[connection]\nprinter_ip = old\n"
|
||||
)
|
||||
bridge._find_config_path = lambda: config_path
|
||||
bridge._restart_bridge = lambda: None
|
||||
|
||||
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
|
||||
content = config_path.read_text()
|
||||
assert "server = http://192.168.1.50:7912" in content
|
||||
assert "printer_ip = 10.0.0.99" in content
|
||||
|
||||
157
tests/test_slot_profile_material_guard.py
Normal file
157
tests/test_slot_profile_material_guard.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Stale slot-profile guard: suppress a saved per-slot filament profile when the
|
||||
AMS now reports a *different material family* than the profile was assigned for.
|
||||
|
||||
Real-world bug (KX1): slot 1 held a PETG spool and got the profile
|
||||
"KINGROON PETG Basic". The user swapped in yellow PLA. The AMS updated the
|
||||
colour (live) but the saved profile stuck on PETG, so the panel + the slicer
|
||||
hint kept showing/using PETG. Restarting did not help — the override lives in
|
||||
config.ini.
|
||||
|
||||
Fix = non-destructive suppression (Option A): resolve the effective profile as
|
||||
"the saved override only if its material *family* matches the current AMS
|
||||
material; otherwise none (fall back to the generic default)". The override is
|
||||
never deleted, so putting the original material back reactivates it.
|
||||
|
||||
Comparison must be by *family*, never strict string equality — PLA / PLA+ /
|
||||
PLA SILK / PLA MATTE are the same family and must NOT invalidate each other
|
||||
(regression guard for the earlier over-strict material compare).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# conftest.py (same dir) already put bridge/ on sys.path and mocked env_loader.
|
||||
from kobrax_moonraker_bridge import KobraXBridge
|
||||
|
||||
# Minimal in-memory stand-in for orca_filaments.json (id, name, vendor, type).
|
||||
LIBRARY = [
|
||||
{"vendor": "KINGROON", "name": "KINGROON PETG Basic", "type": "PETG", "id": "PB01"},
|
||||
{"vendor": "KINGROON", "name": "KINGROON PLA Basic", "type": "PLA", "id": "PL01"},
|
||||
{"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "type": "PLA", "id": "PS01"},
|
||||
]
|
||||
|
||||
PETG_PROFILE = {"vendor": "KINGROON", "name": "KINGROON PETG Basic", "id": "PB01"}
|
||||
SILK_PROFILE = {"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "id": "PS01"}
|
||||
UNKNOWN_PROFILE = {"vendor": "Foo", "name": "Foo Bar Filament", "id": "X99"}
|
||||
|
||||
|
||||
def _bridge():
|
||||
c = MagicMock()
|
||||
c.callbacks = {}
|
||||
c.connected = False
|
||||
args = argparse.Namespace(
|
||||
printer_ip="", mqtt_port=9883, username="", password="",
|
||||
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
||||
data_dir=tempfile.mkdtemp(prefix="kxguard-"),
|
||||
)
|
||||
b = KobraXBridge(c, args=args)
|
||||
b._orca_filaments_cache = LIBRARY # _load_orca_filaments() returns this as-is
|
||||
return b
|
||||
|
||||
|
||||
# ── _material_family ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_material_family_collapses_pla_variants():
|
||||
fam = KobraXBridge._material_family
|
||||
assert fam("PLA") == "PLA"
|
||||
assert fam("PLA+") == "PLA"
|
||||
assert fam("PLA SILK") == "PLA"
|
||||
assert fam("PLA MATTE") == "PLA"
|
||||
assert fam("Silk PLA") == "PLA" # alias-normalised before family reduction
|
||||
|
||||
|
||||
def test_material_family_collapses_petg_variants():
|
||||
fam = KobraXBridge._material_family
|
||||
assert fam("PETG") == "PETG"
|
||||
assert fam("PETG+") == "PETG"
|
||||
|
||||
|
||||
def test_material_family_distinguishes_pla_from_petg():
|
||||
fam = KobraXBridge._material_family
|
||||
assert fam("PLA") != fam("PETG")
|
||||
assert fam("PLA SILK") != fam("PETG")
|
||||
|
||||
|
||||
def test_material_family_empty_for_empty_input():
|
||||
assert KobraXBridge._material_family("") == ""
|
||||
assert KobraXBridge._material_family(None) == ""
|
||||
|
||||
|
||||
# ── _effective_slot_profile ───────────────────────────────────────────────────
|
||||
|
||||
def test_suppressed_when_family_changes_petg_profile_pla_loaded():
|
||||
"""The exact KX1 bug: PETG profile, AMS now reports PLA → suppress."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
assert b._effective_slot_profile(0, "PLA") == {}
|
||||
|
||||
|
||||
def test_kept_when_family_matches_petg_profile_petg_loaded():
|
||||
b = _bridge()
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
assert b._effective_slot_profile(0, "PETG") == PETG_PROFILE
|
||||
|
||||
|
||||
def test_kept_for_pla_variant_no_false_positive():
|
||||
"""PLA+ profile with a PLA SILK spool loaded is the same family → keep."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {1: dict(SILK_PROFILE)}
|
||||
assert b._effective_slot_profile(1, "PLA SILK") == SILK_PROFILE
|
||||
|
||||
|
||||
def test_kept_when_profile_material_unknown_failsafe():
|
||||
"""If the profile is not in the library we cannot know its family → never
|
||||
suppress on uncertainty (fail-safe keeps the user's choice)."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {2: dict(UNKNOWN_PROFILE)}
|
||||
assert b._effective_slot_profile(2, "PLA") == UNKNOWN_PROFILE
|
||||
|
||||
|
||||
def test_empty_when_no_override():
|
||||
b = _bridge()
|
||||
b._filament_profiles = {}
|
||||
assert b._effective_slot_profile(3, "PLA") == {}
|
||||
|
||||
|
||||
# ── Integration: display endpoint (the visible panel) ─────────────────────────
|
||||
|
||||
async def test_display_endpoint_suppresses_stale_petg_when_pla_loaded():
|
||||
"""/kx/filament/slots must not show the stale PETG identity once PLA loads."""
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"global_index": 0, "status": 5, "color": [255, 236, 61], "type": "PLA"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0]
|
||||
assert row["material"] == "PLA" # AMS truth, always
|
||||
assert row["filament_name"] == "" # stale PETG identity gone
|
||||
assert row["filament_vendor"] == ""
|
||||
|
||||
|
||||
async def test_display_endpoint_keeps_profile_when_family_matches():
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"global_index": 0, "status": 5, "color": [10, 20, 30], "type": "PETG"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0]
|
||||
assert row["filament_name"] == "KINGROON PETG Basic"
|
||||
assert row["filament_vendor"] == "KINGROON"
|
||||
|
||||
|
||||
# ── Integration: print path (lane_data sent to OrcaSlicer) ────────────────────
|
||||
|
||||
async def test_lane_data_does_not_leak_stale_petg_identity():
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"status": 5, "color": [255, 236, 61], "type": "PLA"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
tray = b._build_lane_data()["ams"][0]["tray"][0]
|
||||
assert tray["tray_type"] == "PLA"
|
||||
assert "PETG" not in tray["name"].upper()
|
||||
assert tray["vendor_name"] != "KINGROON"
|
||||
|
||||
|
||||
async def test_lane_data_keeps_profile_when_family_matches():
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"status": 5, "color": [10, 20, 30], "type": "PETG"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
tray = b._build_lane_data()["ams"][0]["tray"][0]
|
||||
assert tray["name"] == "KINGROON PETG Basic"
|
||||
assert tray["vendor_name"] == "KINGROON"
|
||||
100
tests/test_spoolman_slot_map.py
Normal file
100
tests/test_spoolman_slot_map.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Per-printer Spoolman slot-map isolation + persistence (config_loader).
|
||||
|
||||
Regression test for two bugs in the Spoolman slot→spool persistence:
|
||||
|
||||
1. The bridge referenced ``config_loader`` while the module alias is
|
||||
``env_loader`` → ``NameError`` swallowed by a bare ``except``, so the map
|
||||
was never loaded nor saved (persistence looked implemented but was dead).
|
||||
2. The map lived in a single global ``[spoolman] slot_spools`` key, so two
|
||||
printers/two AMS units overwrote each other (same class as issue #74/#75).
|
||||
|
||||
Each printer now uses its own ``[spoolman_<id>]`` section, with a read-fallback
|
||||
to the legacy global key for backward compatibility. The global ``[spoolman]``
|
||||
section keeps ``server`` / ``sync_rate``.
|
||||
"""
|
||||
import sys
|
||||
import pathlib
|
||||
import configparser
|
||||
|
||||
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"
|
||||
"[spoolman]\n"
|
||||
"server = http://192.168.3.200:7912\n"
|
||||
"sync_rate = 0\n"
|
||||
"slot_spools = 0:1,1:2\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_read(tmp_path, monkeypatch):
|
||||
"""No printer_id -> original global [spoolman] slot_spools (back-compat)."""
|
||||
_use_ini(monkeypatch, tmp_path)
|
||||
assert config_loader.list_spool_map() == {0: 1, 1: 2}
|
||||
|
||||
|
||||
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_spool_map("1") == {0: 1, 1: 2}
|
||||
assert config_loader.list_spool_map("2") == {0: 1, 1: 2}
|
||||
|
||||
|
||||
def test_saving_one_printer_does_not_touch_the_other(tmp_path, monkeypatch):
|
||||
"""Core regression: mapping printer 1 must not change printer 2."""
|
||||
_use_ini(monkeypatch, tmp_path)
|
||||
config_loader.save_spool_map({0: 42, 1: 17}, "1")
|
||||
assert config_loader.list_spool_map("1") == {0: 42, 1: 17}
|
||||
# printer 2 has no own section yet -> still the global fallback
|
||||
assert config_loader.list_spool_map("2") == {0: 1, 1: 2}
|
||||
# legacy global key preserved untouched
|
||||
assert config_loader.list_spool_map() == {0: 1, 1: 2}
|
||||
|
||||
|
||||
def test_both_printers_isolated_after_each_saves(tmp_path, monkeypatch):
|
||||
_use_ini(monkeypatch, tmp_path)
|
||||
config_loader.save_spool_map({0: 42, 1: 17}, "1")
|
||||
config_loader.save_spool_map({0: 5, 1: 6}, "2")
|
||||
assert config_loader.list_spool_map("1") == {0: 42, 1: 17}
|
||||
assert config_loader.list_spool_map("2") == {0: 5, 1: 6}
|
||||
|
||||
|
||||
def test_save_preserves_server_and_sync_rate(tmp_path, monkeypatch):
|
||||
"""Writing a per-printer map must not clobber [spoolman] server/sync_rate."""
|
||||
path = _use_ini(monkeypatch, tmp_path)
|
||||
config_loader.save_spool_map({0: 42}, "1")
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
assert cfg.get("spoolman", "server") == "http://192.168.3.200:7912"
|
||||
assert cfg.get("spoolman", "sync_rate") == "0"
|
||||
assert cfg.get("spoolman_1", "slot_spools") == "0:42"
|
||||
|
||||
|
||||
def test_persistence_round_trips(tmp_path, monkeypatch):
|
||||
"""Save then read back (simulates a bridge restart) — the map survives."""
|
||||
_use_ini(monkeypatch, tmp_path, text="[spoolman]\nserver = http://x:7912\n")
|
||||
config_loader.save_spool_map({0: 7, 2: 9}, "1")
|
||||
assert config_loader.list_spool_map("1") == {0: 7, 2: 9}
|
||||
|
||||
|
||||
def test_empty_map_clears_the_key(tmp_path, monkeypatch):
|
||||
_use_ini(monkeypatch, tmp_path)
|
||||
config_loader.save_spool_map({0: 42}, "1")
|
||||
config_loader.save_spool_map({}, "1") # clear
|
||||
# per-printer key gone -> falls back to the legacy global map
|
||||
assert config_loader.list_spool_map("1") == {0: 1, 1: 2}
|
||||
|
||||
|
||||
def test_parse_ignores_malformed_and_nonpositive(tmp_path, monkeypatch):
|
||||
_use_ini(monkeypatch, tmp_path,
|
||||
text="[spoolman]\nslot_spools = 0:1, x:y, 2:0, 3:-4, 4:5, junk\n")
|
||||
assert config_loader.list_spool_map() == {0: 1, 4: 5}
|
||||
@@ -45,7 +45,7 @@ var ACE_DRY_PRESETS={
|
||||
};
|
||||
|
||||
// Spoolman state
|
||||
var _spoolmanStatus={configured:false,server:'',sync_rate:0,slot_spools:{}};
|
||||
var _spoolmanStatus={configured:false,reachable:false,server:'',sync_rate:0,slot_spools:{}};
|
||||
var _spoolmanSpools=[];
|
||||
var _slotSpoolMap={}; // {String(global_index): spoolman_spool_id} — last committed assignment
|
||||
|
||||
@@ -54,16 +54,25 @@ function _loadSpoolmanStatus(){
|
||||
_spoolmanStatus=d;
|
||||
_slotSpoolMap=d.slot_spools||{};
|
||||
_updateSpoolmanStatusDot();
|
||||
_buildSpoolmanSection();
|
||||
renderSpoolmanSlotCard();
|
||||
if(d.configured){
|
||||
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(sd){
|
||||
_spoolmanSpools=sd.spools||[];
|
||||
});
|
||||
}
|
||||
}).catch(function(){});
|
||||
}
|
||||
function _updateSpoolmanStatusDot(){
|
||||
var dot=document.getElementById('spoolman-status-dot');
|
||||
var lbl=document.getElementById('spoolman-status-lbl');
|
||||
if(!dot||!lbl)return;
|
||||
if(_spoolmanStatus.configured){
|
||||
if(!_spoolmanStatus.configured){
|
||||
dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert';
|
||||
} else if(_spoolmanStatus.reachable){
|
||||
dot.style.color='var(--ok)';lbl.textContent=_spoolmanStatus.server||'verbunden';
|
||||
} else {
|
||||
dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert';
|
||||
dot.style.color='var(--err)';lbl.textContent=(_spoolmanStatus.server||'')+' (nicht erreichbar)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,12 +87,8 @@ function _buildSpoolmanSection(){
|
||||
if(loading)loading.style.display='';
|
||||
|
||||
var usedSlots={};
|
||||
document.querySelectorAll('#fd-slots select').forEach(function(sel){
|
||||
var idx=parseInt(sel.value);
|
||||
if(idx>=0){
|
||||
var slot=(_amsSlots||[]).find(function(s){return s.slot_index===idx;});
|
||||
if(slot&&!usedSlots[idx])usedSlots[idx]=slot;
|
||||
}
|
||||
(_amsSlots||[]).forEach(function(slot){
|
||||
usedSlots[slot.slot_index]=slot;
|
||||
});
|
||||
|
||||
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(d){
|
||||
@@ -97,7 +102,7 @@ function _buildSpoolmanSection(){
|
||||
var currentSpool=_slotSpoolMap[String(idx)]||'';
|
||||
var opts='<option value="">–</option>'+_spoolmanSpools.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+' ':'';
|
||||
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor.name+' ':'';
|
||||
var name=sp.filament&&sp.filament.name?sp.filament.name:'Spool';
|
||||
return '<option value="'+sp.id+'"'+(sp.id==currentSpool?' selected':'')+'>'+
|
||||
escHtml('#'+sp.id+' '+vendor+name+rem)+'</option>';
|
||||
@@ -289,7 +294,7 @@ function renderPrinterDropdown(){
|
||||
menu.innerHTML=_printers.map(function(p){
|
||||
var active=_activePrinter&&String(p.id)===String(_activePrinter.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>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -376,12 +381,10 @@ function applyLang(){
|
||||
setText('d-chart-label',T.panel_temps_chart);
|
||||
// Axis labels
|
||||
setText('ptitle-motion-xy',T.panel_motion_xy);
|
||||
setText('ptitle-motion-z',T.panel_motion_z);
|
||||
document.querySelectorAll('.lbl-home-z').forEach(e=>e.textContent=T.btn_home_z);
|
||||
document.querySelectorAll('.lbl-home-xy').forEach(e=>e.textContent=T.btn_home_xy);
|
||||
document.querySelectorAll('.lbl-home-all').forEach(e=>e.textContent=T.btn_home_all);
|
||||
document.querySelectorAll('.lbl-disable-motors').forEach(e=>e.textContent=T.btn_disable_motors);
|
||||
document.querySelectorAll('.lbl-step').forEach(e=>e.textContent=T.label_step);
|
||||
document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':','')));
|
||||
// Console
|
||||
setText('ptitle-console',T.panel_console_title);
|
||||
@@ -405,6 +408,16 @@ function applyLang(){
|
||||
setText('lbl-set-lang',T.settings_cat_language||'Sprache');
|
||||
setText('lbl-set-theme',T.settings_cat_theme||'Hell / Dunkel umschalten');
|
||||
setText('lbl-poll-interval',T.settings_poll||'Poll-Intervall (Sekunden)');
|
||||
setText('lbl-verbose-http-log',T.settings_verbose_http_log||'Log every HTTP request (verbose)');
|
||||
var dashLbl=document.getElementById('dash-lbl-edit');
|
||||
if(dashLbl)dashLbl.textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard');
|
||||
setText('dash-lbl-reset',T.dash_reset||'Reset');
|
||||
setText('dash-lbl-save-preset',T.dash_save_preset||'Save as preset');
|
||||
var dashPresetStd=document.getElementById('dash-preset-standard');
|
||||
if(dashPresetStd)dashPresetStd.textContent=T.dash_preset_standard||'Standard';
|
||||
var dashPresetWide=document.getElementById('dash-preset-wide89');
|
||||
if(dashPresetWide)dashPresetWide.textContent=T.dash_preset_wide89||'Wide desktop';
|
||||
if(document.getElementById('dash-hidden-bar'))_dashRenderHiddenBar();
|
||||
setText('lbl-filament-mapping',T.settings_filament_mapping||'Filament-Profil-Mapping (pro Slot)');
|
||||
setText('lbl-filament-mapping-save',T.settings_filament_mapping_save||'Mapping speichern');
|
||||
setText('lbl-visible-vendors',T.settings_visible_vendors||'Sichtbare Hersteller (Profil-Dropdown)');
|
||||
@@ -432,6 +445,7 @@ function applyLang(){
|
||||
setText('lbl-default-slot',T.settings_default_slot);
|
||||
setText('opt-slot-auto',T.settings_slot_auto);
|
||||
setText('lbl-auto-leveling',T.settings_auto_leveling);
|
||||
setText('lbl-vibration-compensation',T.settings_vibration_compensation);
|
||||
setText('lbl-file-ready-mode',T.settings_file_ready_mode);
|
||||
setText('opt-file-ready-dialog',T.settings_file_ready_dialog);
|
||||
setText('opt-file-ready-banner',T.settings_file_ready_banner);
|
||||
@@ -732,6 +746,17 @@ function applyState(){
|
||||
// connection error banner – nur wenn überhaupt ein Drucker konfiguriert ist
|
||||
var banner=document.getElementById('conn-error-banner');
|
||||
if(banner){if(s.connection_error&&_printers.length>0){banner.textContent='⚠ '+tr('lbl_conn_error')+' '+s.connection_error;banner.style.display='block';}else{banner.style.display='none';}}
|
||||
var pauseBanner=document.getElementById('pause-msg-banner');
|
||||
if(pauseBanner){
|
||||
if(s.pause_msg && s.print_state==='paused'){
|
||||
var codePart = (s.error_code==0) ? ' ' :
|
||||
' [<a href="https://wiki.anycubic.com/en/error-codes/'+s.error_code+'-code" target="_blank">'+s.error_code+'</a>] ';
|
||||
pauseBanner.innerHTML='⏸ '+tr('lbl_pause_reason')+codePart+s.pause_msg;
|
||||
pauseBanner.style.display='block';
|
||||
}else{
|
||||
pauseBanner.style.display='none';
|
||||
}
|
||||
}
|
||||
var bannerVisible=false;
|
||||
var frb=document.getElementById('file-ready-banner');
|
||||
if(frb){
|
||||
@@ -749,7 +774,7 @@ function applyState(){
|
||||
frb.style.display='none';
|
||||
if(!_fdDialogOpen&&!_fdUserCancelled&&_fdAutoOpenedFile!==s.file_ready){
|
||||
_fdAutoOpenedFile=s.file_ready;
|
||||
startReadyFileWithSlots(s.file_ready,true);
|
||||
startReadyFileWithSlots(s.file_ready,true,s.filament_mismatch||null);
|
||||
}
|
||||
} else {
|
||||
frb.style.display='flex';
|
||||
@@ -962,6 +987,22 @@ function applyState(){
|
||||
var tt=(profile.name||'')+(profile.id?' ('+profile.id+')':'');
|
||||
vendorBadge='<div class="slot-label" style="font-size:9px;color:var(--accent);font-weight:600;margin-top:1px" title="'+tt+'">'+profile.vendor+'</div>';
|
||||
}
|
||||
var spoolSel='';
|
||||
if(_spoolmanStatus.configured&&!empty&&_spoolmanSpools.length){
|
||||
var curSpool=_slotSpoolMap[String(globalIdx)]||'';
|
||||
var spoolOpts='<option value="">–</option>'+_spoolmanSpools.map(function(sp){
|
||||
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor.name+' ':'';
|
||||
var name=sp.filament?sp.filament.name:'#'+sp.id;
|
||||
var rem=sp.remaining_weight!=null?' '+sp.remaining_weight.toFixed(0)+'g':'';
|
||||
return '<option value="'+sp.id+'"'+(String(sp.id)==String(curSpool)?' selected':'')+'>'+
|
||||
escHtml(vendor+name+rem)+'</option>';
|
||||
}).join('');
|
||||
spoolSel='<div onclick="event.stopPropagation()" style="margin-top:6px;border-top:1px solid rgba(255,255,255,.07);padding-top:5px">'
|
||||
+'<div style="font-size:8px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px">🧵 Spoolman</div>'
|
||||
+'<select data-spool-slot="'+globalIdx+'" onchange="onAmsSpoolChange(this)" '
|
||||
+'style="width:100%;padding:3px 5px;font-size:10px;border-radius:5px;border:1px solid var(--border);background:var(--card);color:var(--txt);cursor:pointer">'+spoolOpts+'</select>'
|
||||
+'</div>';
|
||||
}
|
||||
html+='<div class="ams-slot'+(active?' active':'')+(loaded?' loaded':'')+(activity?' '+activity:'')+(empty?' empty':'')
|
||||
+'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
|
||||
+'<div class="slot-circle" style="background:'+col+'"></div>'
|
||||
@@ -969,6 +1010,7 @@ function applyState(){
|
||||
+vendorBadge
|
||||
+'<div class="slot-label">'+slotLabel+'</div>'
|
||||
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
|
||||
+spoolSel
|
||||
+'<div style="font-size:9px;color:var(--txt2);margin-top:2px">✏</div>'
|
||||
+'</div>';
|
||||
});
|
||||
@@ -979,7 +1021,10 @@ function applyState(){
|
||||
}
|
||||
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
|
||||
@@ -1065,6 +1110,7 @@ function openSettings(){
|
||||
document.getElementById('s-mode-id').value=d.mode_id||'';
|
||||
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
|
||||
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
|
||||
var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation;
|
||||
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
|
||||
var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0));
|
||||
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
|
||||
@@ -1074,7 +1120,9 @@ function openSettings(){
|
||||
var sec=d.poll_interval||Math.round((parseInt(localStorage.getItem('pollInterval')||'2000'))/1000)||3;
|
||||
pi.value=sec;
|
||||
}
|
||||
var vhl=document.getElementById('s-verbose-http-log');if(vhl)vhl.checked=!!d.verbose_http_log;
|
||||
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);
|
||||
@@ -1222,6 +1270,64 @@ function _vendorCheck(cb){
|
||||
var v=cb.getAttribute('data-vendor');
|
||||
if(cb.checked)_vendorChecklistSel[v]=true; else delete _vendorChecklistSel[v];
|
||||
}
|
||||
function renderSpoolmanSlotCard(){
|
||||
var card=document.getElementById('spoolman-slot-card');
|
||||
var rows=document.getElementById('spoolman-slot-rows');
|
||||
if(!card||!rows)return;
|
||||
if(!_spoolmanStatus.configured){card.style.display='none';return;}
|
||||
card.style.display='';
|
||||
Promise.all([
|
||||
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}),
|
||||
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();})
|
||||
]).then(function(res){
|
||||
var spools=res[0].spools||[];
|
||||
var slots=(res[1].result||[]).sort(function(a,b){return a.slot_index-b.slot_index;});
|
||||
if(!slots.length){rows.innerHTML='<span style="font-size:11px;color:var(--txt2)">Keine AMS-Slots bekannt.</span>';return;}
|
||||
rows.innerHTML=slots.map(function(slot){
|
||||
var idx=parseInt(slot.slot_index);
|
||||
var col=slot.color_hex||'#888';
|
||||
var mat=slot.material||'';
|
||||
var current=_slotSpoolMap[String(idx)]||'';
|
||||
var opts='<option value="">–</option>'+spools.map(function(sp){
|
||||
var rem=sp.remaining_weight!=null?' ('+sp.remaining_weight.toFixed(0)+'g)':'';
|
||||
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor.name+' ':'';
|
||||
var name=sp.filament?sp.filament.name:'Spool #'+sp.id;
|
||||
var mat2=sp.filament&&sp.filament.material?' · '+sp.filament.material:'';
|
||||
return '<option value="'+sp.id+'"'+(String(sp.id)==String(current)?' selected':'')+'>'+
|
||||
escHtml('#'+sp.id+' '+vendor+name+mat2+rem)+'</option>';
|
||||
}).join('');
|
||||
return '<div style="display:flex;align-items:center;gap:8px;font-size:12px">'+
|
||||
'<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:'+col+';border:1px solid var(--border);flex-shrink:0"></span>'+
|
||||
'<span style="color:var(--txt2);min-width:60px">Slot '+(idx+1)+' <span style="color:var(--txt2);font-size:10px">'+escHtml(mat)+'</span></span>'+
|
||||
'<select data-spool-slot="'+idx+'" style="flex:1;padding:3px 6px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);font-size:12px">'+opts+'</select></div>';
|
||||
}).join('');
|
||||
}).catch(function(){rows.innerHTML='<span style="font-size:11px;color:var(--err)">Spoolman nicht erreichbar</span>';});
|
||||
}
|
||||
|
||||
function onAmsSpoolChange(sel){
|
||||
var idx=sel.getAttribute('data-spool-slot');
|
||||
var val=sel.value;
|
||||
if(val) _slotSpoolMap[String(idx)]=parseInt(val);
|
||||
else delete _slotSpoolMap[String(idx)];
|
||||
var mapping={};
|
||||
Object.keys(_slotSpoolMap).forEach(function(k){mapping[k]=_slotSpoolMap[k];});
|
||||
fetch(_apiUrl('/kx/spoolman/active-spool'),{method:'POST',
|
||||
headers:{'Content-Type':'application/json'},body:JSON.stringify({slot_spools:mapping})});
|
||||
}
|
||||
|
||||
function saveSpoolmanSlots(){
|
||||
var mapping={};
|
||||
document.querySelectorAll('#spoolman-slot-rows select[data-spool-slot]').forEach(function(sel){
|
||||
var idx=sel.getAttribute('data-spool-slot');
|
||||
var val=sel.value;
|
||||
if(val)mapping[idx]=parseInt(val);
|
||||
});
|
||||
fetch(_apiUrl('/kx/spoolman/active-spool'),{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({slot_spools:mapping})}).then(function(r){return r.json();}).then(function(d){
|
||||
_slotSpoolMap=d.slot_spools||{};
|
||||
});
|
||||
}
|
||||
|
||||
function saveVisibleVendors(){
|
||||
var vendors=Object.keys(_vendorChecklistSel);
|
||||
fetch(_apiUrl('/kx/filament/visible_vendors'),{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
@@ -1333,7 +1439,14 @@ function doProfileImportUpload(files){
|
||||
// ── AMS Slot Edit ──
|
||||
var _slotEditIndex=-1;
|
||||
var _slotEditLoaded=false;
|
||||
var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS'];
|
||||
var _MAT_PRESETS=['PLA','PLA+','PLA SILK','PLA MATTE','PETG','ABS','ASA','TPU','PA','PC','HIPS'];
|
||||
function _normalizeMat(m){
|
||||
var s=m.toUpperCase().trim().replace(/-/g,' ').replace(/_/g,' ');
|
||||
var aliases={'PLAPLUS':'PLA+','PLA PLUS':'PLA+','SILK PLA':'PLA SILK','PLASILK':'PLA SILK',
|
||||
'PLA MATTE':'PLA','PLA MARBLE':'PLA','PLA WOOD':'PLA','TPE':'TPU',
|
||||
'PETG PLUS':'PETG+','PA6':'PA','PA12':'PA','PA66':'PA'};
|
||||
return aliases[s]||s;
|
||||
}
|
||||
var _BASE_MATERIAL_TYPES=['PLA','PETG','ABS','ASA','TPU','TPE','PA','PC','HIPS','PEI','PEEK'];
|
||||
function updateSlotEditFeedButton(){
|
||||
var btn=document.getElementById('btn-slot-edit-feed');
|
||||
@@ -1374,10 +1487,21 @@ function _fillSlotProfileDropdown(material, currentVendor, currentName){
|
||||
_loadOrcaFilaments(function(profiles){
|
||||
// Type-Filter: nur Profile vom passenden material zeigen (z.B. PLA → alle PLA-Varianten)
|
||||
var matU=(material||'').toUpperCase().trim();
|
||||
// PLA-Varianten: Drucker meldet "PLA SILK"/"PLA+"/"PLA MATTE", OrcaSlicer
|
||||
// speichert alle unter type=PLA — Namens-Keyword als Zusatzfilter.
|
||||
var _PLA_VARIANT_KW={'PLA SILK':'silk','PLA+':'pla+','PLA MATTE':'matte',
|
||||
'PLA MARBLE':'marble','PLA WOOD':'wood'};
|
||||
var variantKw=_PLA_VARIANT_KW[matU]||null;
|
||||
var baseMat=variantKw?'PLA':matU;
|
||||
var matched=profiles.filter(function(p){
|
||||
var pt=(p.type||'').toUpperCase();
|
||||
// PLA-CF, PLA-SILK etc. zählen auch zu PLA
|
||||
return matU==='' || pt===matU || pt.startsWith(matU+'-') || pt.startsWith(matU+' ');
|
||||
var nameL=(p.name||'').toLowerCase();
|
||||
// Basis-Typ muss passen
|
||||
var typeOk=baseMat===''||pt===baseMat||pt.startsWith(baseMat+'-')||pt.startsWith(baseMat+' ');
|
||||
if(!typeOk) return false;
|
||||
// Bei Variante: Name muss Keyword enthalten (z.B. "silk", "matte", "pla+")
|
||||
if(variantKw) return nameL.indexOf(variantKw)!==-1;
|
||||
return true;
|
||||
});
|
||||
sel.innerHTML='<option value="">'+tr('slot_edit_profile_default')+'</option>';
|
||||
// User-Profile (is_user) zuerst — eigene Optgroup '★ Eigene' an erster Stelle.
|
||||
@@ -1419,6 +1543,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){
|
||||
var slot=(window._amsSlots||[])[i]||{};
|
||||
var globalIdx=slot.global_index!=null?slot.global_index:(slot.index!=null?slot.index:i);
|
||||
@@ -1430,13 +1658,19 @@ function openSlotEdit(i){
|
||||
var ci=document.getElementById('slot-edit-color');
|
||||
ci.value=hex;
|
||||
document.getElementById('slot-edit-preview').style.background=hex;
|
||||
_initPickr(hex);
|
||||
_renderSwatches();
|
||||
_renderCopyFromSlot(globalIdx);
|
||||
var mat=(slot.type||'PLA').toUpperCase();
|
||||
document.getElementById('slot-edit-mat').value=mat;
|
||||
// Normalisieren für Button-Highlighting: PLA-Varianten auf nächsten Preset mappen
|
||||
var matNorm=_normalizeMat(mat);
|
||||
var btns=document.getElementById('slot-mat-btns');
|
||||
btns.innerHTML=_MAT_PRESETS.map(function(m){
|
||||
var active=m===mat||m===matNorm;
|
||||
return '<button class="mat-preset-btn" data-mat="'+m+'" onclick="selectMatPreset(\''+m+'\')" '
|
||||
+'style="padding:4px 10px;border-radius:6px;border:1px solid var(--border);cursor:pointer;font-size:12px;'
|
||||
+(m===mat?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
|
||||
+(active?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
|
||||
}).join('');
|
||||
// OrcaSlicer-Profil-Dropdown: aktuellen User-Override für diesen Slot
|
||||
// aus /kx/filament/slots holen (enthält vendor+name+id).
|
||||
@@ -1554,6 +1788,7 @@ function hexToRgb(hex){
|
||||
}
|
||||
function saveSlotEdit(){
|
||||
var hex=document.getElementById('slot-edit-color').value;
|
||||
_addSwatch(hex);
|
||||
var mat=document.getElementById('slot-edit-mat').value.trim().toUpperCase()||'PLA';
|
||||
var color=hexToRgb(hex);
|
||||
var slotIdx=_slotEditIndex;
|
||||
@@ -1642,11 +1877,13 @@ function saveSettings(){
|
||||
device_id: document.getElementById('s-device-id').value,
|
||||
mode_id: document.getElementById('s-mode-id').value,
|
||||
default_ams_slot: document.getElementById('s-default-slot').value,
|
||||
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
||||
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
|
||||
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
||||
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
|
||||
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
|
||||
print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10),
|
||||
web_upload_warning:webUploadWarning,
|
||||
poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)),
|
||||
verbose_http_log: (document.getElementById('s-verbose-http-log')||{}).checked?1:0,
|
||||
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(){
|
||||
@@ -1753,8 +1990,314 @@ var pollTimer;
|
||||
});
|
||||
}).catch(function(){});
|
||||
poll();pollTimer=setInterval(poll,ms);
|
||||
setInterval(_loadSpoolmanStatus,30000);
|
||||
// initDashGrid() is called at the very end of the file, after all the
|
||||
// DASH_* var declarations below have executed (they are hoisted but not yet
|
||||
// assigned at this point in the IIFE).
|
||||
})();
|
||||
|
||||
// ── Dashboard Free Grid (Issue #89) ────────────────────────────────────────
|
||||
// User-customizable dashboard powered by GridStack (v10, docs: gridstack.js).
|
||||
// Cards move + resize on a 12-column snap grid; layout persisted per browser
|
||||
// in localStorage. Built on documented APIs only:
|
||||
// - float:false (default) -> compact packing, no holes between cards
|
||||
// - staticGrid:true -> locked by default, setStatic(false) in edit mode
|
||||
// - grid.save(false)/load(layout,false) -> serialize/restore by gs-id
|
||||
// - draggable.cancel -> inputs/buttons/img excluded from drag start
|
||||
// - columnOpts.breakpoints -> 1-column below 700px GRID width (sidebar-aware)
|
||||
var DASH_STORAGE_KEY='dashLayout';
|
||||
var DASH_LAYOUT_VERSION=3; // v1/v2 = older editors; discard those states
|
||||
var DASH_CARD_KEYS=['camera','progress','temps','motion','speed','fan','ams'];
|
||||
// Layout arrays in GridStack save()/load() format. cellHeight=60px.
|
||||
var DASH_DEFAULT_LAYOUT=[
|
||||
{id:'camera', x:0,y:0, w:12,h:7},
|
||||
{id:'progress',x:0,y:7, w:12,h:6},
|
||||
{id:'temps', x:0,y:13,w:6, h:7},
|
||||
{id:'motion', x:6,y:13,w:6, h:7},
|
||||
{id:'speed', x:0,y:20,w:6, h:3},
|
||||
{id:'fan', x:6,y:20,w:6, h:3},
|
||||
{id:'ams', x:0,y:23,w:12,h:4},
|
||||
];
|
||||
// "Wide desktop" preset (Blaim, Issue #89): big camera left, settings center,
|
||||
// motion right.
|
||||
var DASH_PRESET_WIDE=[
|
||||
{id:'progress',x:0, y:0, w:12,h:4},
|
||||
{id:'camera', x:0, y:4, w:6, h:11},
|
||||
{id:'temps', x:6, y:4, w:3, h:7},
|
||||
{id:'speed', x:6, y:11,w:3, h:2},
|
||||
{id:'fan', x:6, y:13,w:3, h:2},
|
||||
{id:'motion', x:9, y:4, w:3, h:11},
|
||||
{id:'ams', x:0, y:15,w:12,h:4},
|
||||
];
|
||||
var _dashGrid=null;
|
||||
var _dashEditing=false;
|
||||
var _dashHidden=[]; // [{id,x,y,w,h}] cards currently hidden (position kept for re-show)
|
||||
|
||||
// GridStack requires .grid-stack-item > .grid-stack-item-content around each
|
||||
// card — wrap the existing .card elements in place (IDs/event handlers survive,
|
||||
// appendChild moves nodes without recreating them).
|
||||
function _dashWrapCards(){
|
||||
var grid=document.getElementById('dash-grid');
|
||||
if(!grid)return;
|
||||
DASH_CARD_KEYS.forEach(function(key){
|
||||
var card=grid.querySelector('[data-card="'+key+'"]');
|
||||
if(!card||card.parentNode.classList.contains('grid-stack-item-content'))return;
|
||||
var item=document.createElement('div');
|
||||
item.className='grid-stack-item';
|
||||
item.setAttribute('gs-id',key);
|
||||
var content=document.createElement('div');
|
||||
content.className='grid-stack-item-content';
|
||||
grid.insertBefore(item,card);
|
||||
item.appendChild(content);
|
||||
content.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function _dashItem(key){
|
||||
return document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"]');
|
||||
}
|
||||
|
||||
function _dashLoadState(){
|
||||
try{
|
||||
var raw=localStorage.getItem(DASH_STORAGE_KEY);
|
||||
if(!raw)return null;
|
||||
var s=JSON.parse(raw);
|
||||
if(!s||s.version!==DASH_LAYOUT_VERSION||!Array.isArray(s.layout))return null;
|
||||
return s;
|
||||
}catch(e){return null;}
|
||||
}
|
||||
function _dashSaveState(){
|
||||
if(!_dashGrid)return;
|
||||
localStorage.setItem(DASH_STORAGE_KEY,JSON.stringify({
|
||||
version:DASH_LAYOUT_VERSION,
|
||||
layout:_dashGrid.save(false), // [{id,x,y,w,h},…] — visible widgets only
|
||||
hidden:_dashHidden.slice(),
|
||||
}));
|
||||
}
|
||||
|
||||
function initDashGrid(){
|
||||
var el=document.getElementById('dash-grid');
|
||||
if(!el||typeof GridStack==='undefined')return;
|
||||
_dashWrapCards();
|
||||
_dashGrid=GridStack.init({
|
||||
cellHeight:60,
|
||||
margin:8,
|
||||
staticGrid:true, // locked by default; setStatic(false) enables drag+resize
|
||||
columnOpts:{breakpoints:[{w:700,c:1}]}, // grid-width based (sidebar-aware)
|
||||
draggable:{cancel:'input,textarea,button,select,option,img,.slider'},
|
||||
},el);
|
||||
var state=_dashLoadState();
|
||||
if(state){
|
||||
_dashHidden=Array.isArray(state.hidden)?state.hidden:[];
|
||||
_dashGrid.load(state.layout,false); // update matching ids, no add/remove
|
||||
_dashHidden.forEach(function(n){
|
||||
var item=_dashItem(n.id);
|
||||
if(item){_dashGrid.removeWidget(item,false);item.style.display='none';}
|
||||
});
|
||||
}else{
|
||||
_dashGrid.load(DASH_DEFAULT_LAYOUT,false);
|
||||
}
|
||||
_dashGrid.on('change',function(){if(_dashEditing)_dashSaveState();});
|
||||
_dashRefreshPresetDropdown();
|
||||
}
|
||||
|
||||
function toggleDashEdit(){
|
||||
if(!_dashGrid)return;
|
||||
_dashEditing=!_dashEditing;
|
||||
_dashGrid.setStatic(!_dashEditing);
|
||||
document.getElementById('dash-grid').classList.toggle('editing',_dashEditing);
|
||||
document.getElementById('dash-lbl-edit').textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard');
|
||||
document.getElementById('dash-reset-btn').style.display=_dashEditing?'':'none';
|
||||
document.getElementById('dash-preset').style.display=_dashEditing?'':'none';
|
||||
document.getElementById('dash-preset-save-btn').style.display=_dashEditing?'':'none';
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
if(_dashEditing)_dashInjectControls(); else _dashRemoveControls();
|
||||
_dashRenderHiddenBar();
|
||||
if(!_dashEditing)_dashSaveState();
|
||||
}
|
||||
|
||||
// ── Custom presets (user-saved layouts) ────────────────────────────────────
|
||||
var DASH_CUSTOM_PRESETS_KEY='dashCustomPresets';
|
||||
var DASH_BUILTIN_PRESET_IDS=['standard','wide89'];
|
||||
|
||||
function _dashLoadCustomPresets(){
|
||||
try{
|
||||
var raw=localStorage.getItem(DASH_CUSTOM_PRESETS_KEY);
|
||||
var obj=raw?JSON.parse(raw):{};
|
||||
return (obj&&typeof obj==='object')?obj:{};
|
||||
}catch(e){return {};}
|
||||
}
|
||||
function _dashSaveCustomPresets(presets){
|
||||
localStorage.setItem(DASH_CUSTOM_PRESETS_KEY,JSON.stringify(presets));
|
||||
}
|
||||
|
||||
function _dashRefreshPresetDropdown(){
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(!sel)return;
|
||||
var current=sel.value;
|
||||
var customs=_dashLoadCustomPresets();
|
||||
// Remove previously-rendered custom <option>s, keep the two built-ins.
|
||||
Array.prototype.slice.call(sel.querySelectorAll('option')).forEach(function(o){
|
||||
if(DASH_BUILTIN_PRESET_IDS.indexOf(o.value)===-1)sel.removeChild(o);
|
||||
});
|
||||
Object.keys(customs).sort().forEach(function(name){
|
||||
var opt=document.createElement('option');
|
||||
opt.value='custom:'+name;
|
||||
opt.textContent=name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if(Array.prototype.some.call(sel.options,function(o){return o.value===current;}))sel.value=current;
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
}
|
||||
|
||||
function _dashUpdatePresetDeleteBtn(){
|
||||
var sel=document.getElementById('dash-preset');
|
||||
var delBtn=document.getElementById('dash-preset-delete-btn');
|
||||
if(!sel||!delBtn)return;
|
||||
var isCustom=sel.value.indexOf('custom:')===0;
|
||||
delBtn.style.display=(_dashEditing&&isCustom)?'':'none';
|
||||
}
|
||||
|
||||
function saveCurrentAsDashPreset(){
|
||||
if(!_dashGrid)return;
|
||||
var name=(prompt(T.dash_preset_name_prompt||'Preset name:')||'').trim();
|
||||
if(!name)return;
|
||||
var customs=_dashLoadCustomPresets();
|
||||
customs[name]=_dashGrid.save(false);
|
||||
_dashSaveCustomPresets(customs);
|
||||
_dashRefreshPresetDropdown();
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(sel)sel.value='custom:'+name;
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
}
|
||||
|
||||
function deleteCurrentDashPreset(){
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(!sel||sel.value.indexOf('custom:')!==0)return;
|
||||
var name=sel.value.slice(7);
|
||||
if(!confirm((T.dash_preset_delete_confirm||'Delete preset "{name}"?').replace('{name}',name)))return;
|
||||
var customs=_dashLoadCustomPresets();
|
||||
delete customs[name];
|
||||
_dashSaveCustomPresets(customs);
|
||||
sel.value='standard';
|
||||
_dashRefreshPresetDropdown();
|
||||
resetDashLayout();
|
||||
}
|
||||
|
||||
function _dashInjectControls(){
|
||||
DASH_CARD_KEYS.forEach(function(key){
|
||||
if(_dashHidden.some(function(n){return n.id===key;}))return;
|
||||
var content=document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"] .grid-stack-item-content');
|
||||
if(!content||content.querySelector('.dash-card-controls'))return;
|
||||
var ctrl=document.createElement('div');
|
||||
ctrl.className='dash-card-controls';
|
||||
var hideBtn=document.createElement('button');
|
||||
hideBtn.className='dash-card-ctrl-btn';
|
||||
hideBtn.title=T.dash_hide||'Hide';
|
||||
hideBtn.textContent='👁';
|
||||
hideBtn.addEventListener('click',function(e){e.stopPropagation();dashHideCard(key);});
|
||||
ctrl.appendChild(hideBtn);
|
||||
content.appendChild(ctrl);
|
||||
});
|
||||
}
|
||||
function _dashRemoveControls(){
|
||||
var nodes=document.querySelectorAll('#dash-grid .dash-card-controls');
|
||||
Array.prototype.forEach.call(nodes,function(n){n.parentNode&&n.parentNode.removeChild(n);});
|
||||
}
|
||||
|
||||
function _dashCardLabel(key){
|
||||
var card=document.querySelector('[data-card="'+key+'"]');
|
||||
if(!card)return key;
|
||||
var t=card.querySelector('.card-title');
|
||||
return t?t.textContent.trim():key;
|
||||
}
|
||||
|
||||
function _dashRenderHiddenBar(){
|
||||
var bar=document.getElementById('dash-hidden-bar');
|
||||
if(!bar)return;
|
||||
bar.innerHTML='';
|
||||
if(!_dashEditing||!_dashHidden.length){bar.classList.remove('show');return;}
|
||||
bar.classList.add('show');
|
||||
var label=document.createElement('span');
|
||||
label.textContent=(T.dash_hidden_cards||'Hidden cards')+': ';
|
||||
bar.appendChild(label);
|
||||
_dashHidden.forEach(function(n){
|
||||
var chip=document.createElement('span');
|
||||
chip.className='dash-hidden-chip';
|
||||
chip.appendChild(document.createTextNode(_dashCardLabel(n.id)+' '));
|
||||
var btn=document.createElement('button');
|
||||
btn.textContent='👁';
|
||||
btn.title=T.dash_show||'Show';
|
||||
btn.addEventListener('click',function(){dashShowCard(n.id);});
|
||||
chip.appendChild(btn);
|
||||
bar.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function dashHideCard(key){
|
||||
if(_dashHidden.some(function(n){return n.id===key;}))return;
|
||||
var item=_dashItem(key);
|
||||
if(!item||!_dashGrid)return;
|
||||
// Remember position/size so re-show can restore it.
|
||||
var n=item.gridstackNode||{};
|
||||
_dashHidden.push({id:key,x:n.x,y:n.y,w:n.w,h:n.h});
|
||||
_dashGrid.removeWidget(item,false); // keep DOM element, drop the widget
|
||||
item.style.display='none';
|
||||
_dashSaveState();
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
function dashShowCard(key){
|
||||
var entry=null;
|
||||
_dashHidden=_dashHidden.filter(function(n){if(n.id===key){entry=n;return false;}return true;});
|
||||
var item=_dashItem(key);
|
||||
if(item&&_dashGrid){
|
||||
item.style.display='';
|
||||
_dashGrid.makeWidget(item);
|
||||
if(entry&&entry.w)_dashGrid.update(item,{x:entry.x,y:entry.y,w:entry.w,h:entry.h});
|
||||
if(_dashEditing)_dashInjectControls();
|
||||
}
|
||||
_dashSaveState();
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
|
||||
function _dashShowAllHidden(){
|
||||
_dashHidden.slice().forEach(function(n){dashShowCard(n.id);});
|
||||
_dashHidden=[];
|
||||
}
|
||||
|
||||
function resetDashLayout(){
|
||||
if(!_dashGrid)return;
|
||||
localStorage.removeItem(DASH_STORAGE_KEY);
|
||||
var sel=document.getElementById('dash-preset');if(sel)sel.value='standard';
|
||||
_dashShowAllHidden();
|
||||
_dashGrid.load(DASH_DEFAULT_LAYOUT,false);
|
||||
if(_dashEditing){_dashInjectControls();_dashSaveState();}
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
function applyDashPreset(name){
|
||||
if(name==='standard'){resetDashLayout();return;}
|
||||
if(!_dashGrid)return;
|
||||
var layout=null;
|
||||
if(name==='wide89'){
|
||||
layout=DASH_PRESET_WIDE;
|
||||
}else if(name.indexOf('custom:')===0){
|
||||
var customs=_dashLoadCustomPresets();
|
||||
layout=customs[name.slice(7)];
|
||||
}
|
||||
if(!layout)return;
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(sel)sel.value=name; // keep the dropdown in sync even when called directly
|
||||
_dashShowAllHidden();
|
||||
_dashGrid.load(layout,false);
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
if(_dashEditing)_dashInjectControls();
|
||||
_dashSaveState();
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
|
||||
// Init now — all DASH_* declarations above have run, and app.js loads at
|
||||
// </body>, so the DOM is ready.
|
||||
initDashGrid();
|
||||
// ── Print actions ──
|
||||
function printAction(a){
|
||||
post('/printer/print/'+a,{}).then(function(){clog('Druck: '+a,'msg-ok');poll()})
|
||||
@@ -1791,7 +2334,15 @@ function setStep(btn,v){
|
||||
currentStep=v;
|
||||
document.querySelectorAll('.step-btn').forEach(b=>b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('step-display').textContent=v;
|
||||
var ci=document.getElementById('step-custom');
|
||||
if(ci)ci.value='';
|
||||
}
|
||||
function setStepCustom(inp){
|
||||
var v=parseFloat(inp.value);
|
||||
if(!isNaN(v)&&v>0){
|
||||
currentStep=v;
|
||||
document.querySelectorAll('.step-btn').forEach(b=>b.classList.remove('active'));
|
||||
}
|
||||
}
|
||||
function move(axis,dir,dist){
|
||||
// axis: 0=X,1=Y,2=Z → printer axis codes: 1=X,2=Y,3=Z
|
||||
@@ -2486,7 +3037,20 @@ function confirmStoreWebVerify(){
|
||||
});
|
||||
}
|
||||
|
||||
function startReadyFileWithSlots(filename,_autoOpen){
|
||||
function _showMismatchWarn(mismatches){
|
||||
var el=document.getElementById('fd-mismatch-warn');
|
||||
if(!el)return;
|
||||
if(!mismatches||!mismatches.length){el.style.display='none';el.innerHTML='';return;}
|
||||
var lines=mismatches.map(function(m){
|
||||
var slot='Slot '+(m.slot_index+1);
|
||||
if(m.reason==='empty')
|
||||
return '⚠ '+slot+': GCode needs '+m.gcode_material+' — slot is empty';
|
||||
return '⚠ '+slot+': GCode needs '+m.gcode_material+', loaded: '+(m.ams_material||'?');
|
||||
});
|
||||
el.innerHTML='<strong>Filament mismatch detected</strong><br>'+lines.join('<br>');
|
||||
el.style.display='';
|
||||
}
|
||||
function startReadyFileWithSlots(filename,_autoOpen,_mismatch){
|
||||
if(!_autoOpen) _fdAutoOpenedFile=null; // manueller Aufruf → Auto-Open-Sperre aufheben
|
||||
var fn=filename||S.file_ready;
|
||||
var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;});
|
||||
@@ -2506,9 +3070,11 @@ function startReadyFileWithSlots(filename,_autoOpen){
|
||||
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){
|
||||
if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;}
|
||||
openFilamentDialog(d.result||[]);
|
||||
_showMismatchWarn(_mismatch||null);
|
||||
}).catch(function(){
|
||||
if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;}
|
||||
openFilamentDialog([]);
|
||||
_showMismatchWarn(_mismatch||null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2744,6 +3310,7 @@ function openFilamentDialog(slots){
|
||||
function closeFilamentDialog(){
|
||||
var dlg=document.getElementById('filament-dialog');
|
||||
if(dlg)dlg.classList.remove('open');
|
||||
_showMismatchWarn(null);
|
||||
_fdDialogOpen=false;
|
||||
if(_fdAutoOpenedFile){
|
||||
_fdUserCancelled=true;
|
||||
@@ -3142,7 +3709,7 @@ function loadPrinterTab(){
|
||||
'<div style="font-size:12px;color:var(--txt2);display:flex;gap:12px">'+
|
||||
'<span>🌡 '+nt+'°C</span><span>🛏 '+bt+'°C</span>'+
|
||||
'</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>';
|
||||
}).join('');
|
||||
});
|
||||
|
||||
@@ -5,9 +5,14 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>KX-Bridge</title>
|
||||
<link rel="stylesheet" href="/kx/ui/style.css">
|
||||
<link rel="stylesheet" href="/kx/ui/lib/pickr-nano.min.css">
|
||||
<link rel="stylesheet" href="/kx/ui/lib/gridstack.min.css">
|
||||
<script src="/kx/ui/lib/pickr.min.js"></script>
|
||||
<script src="/kx/ui/lib/gridstack-all.min.js"></script>
|
||||
<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="pause-msg-banner" style="display:none;background:#b8860b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:997;"></div>
|
||||
<div id="file-ready-banner" style="display:none;background:#1a6e3c;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:998;display:none;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap">
|
||||
<span>📄 <span id="file-ready-name"></span></span>
|
||||
<button id="file-ready-btn" onclick="startReadyFile()"
|
||||
@@ -32,17 +37,6 @@
|
||||
<span id="h-version" style="font-size:11px;opacity:.5;margin-left:6px"></span>
|
||||
<div class="hbadge" id="h-badge"><span class="dot"></span><span id="h-state">Standby</span></div>
|
||||
<button class="theme-btn" onclick="toggleTheme()">☀ / ☾</button>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span aria-hidden="true" style="font-size:15px;line-height:1;opacity:.85">🌐</span>
|
||||
<select class="theme-btn" id="lang-select" onchange="setLanguageFromSelect()" style="padding:6px 10px">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Espanol</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="it">Italiano</option>
|
||||
<option value="zh-cn">中文(简体)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="theme-btn" onclick="showPanel('settings')" id="settings-btn" title="Einstellungen">⚙</button>
|
||||
<button class="conn-btn disconnected" id="conn-btn" onclick="toggleConnection()">⚡ Verbinden</button>
|
||||
</header>
|
||||
@@ -57,15 +51,26 @@
|
||||
<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>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
|
||||
<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 style="flex:1">
|
||||
<div style="font-size:11px;color:var(--txt2);margin-bottom:4px" id="lbl-slot-color"></div>
|
||||
<input type="color" id="slot-edit-color"
|
||||
oninput="document.getElementById('slot-edit-preview').style.background=this.value"
|
||||
style="width:100%;height:36px;border:1px solid var(--border);border-radius:6px;background:var(--raised);cursor:pointer;padding:2px">
|
||||
<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;margin-top:4px"></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-color"></div>
|
||||
<!-- Pickr anchor — JS mounts the picker here -->
|
||||
<div id="slot-pickr-anchor"></div>
|
||||
<!-- hidden input keeps the hex value for saveSlotEdit() -->
|
||||
<input type="hidden" id="slot-edit-color">
|
||||
</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="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">
|
||||
@@ -135,9 +140,19 @@
|
||||
<main>
|
||||
<!-- ═══ DASHBOARD ═══ -->
|
||||
<div class="panel active" id="panel-dashboard">
|
||||
<div class="grid">
|
||||
<div id="dash-toolbar" style="display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-bottom:10px">
|
||||
<select id="dash-preset" onchange="applyDashPreset(this.value)" style="display:none;padding:4px 8px;font-size:12px;background:var(--raised);color:var(--txt);border:1px solid var(--border);border-radius:6px">
|
||||
<option value="standard" id="dash-preset-standard">Standard</option>
|
||||
<option value="wide89" id="dash-preset-wide89">Desktop breit</option>
|
||||
</select>
|
||||
<button id="dash-preset-delete-btn" onclick="deleteCurrentDashPreset()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--err);border:1px solid var(--border);border-radius:6px;cursor:pointer">🗑</button>
|
||||
<button id="dash-preset-save-btn" onclick="saveCurrentAsDashPreset()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">💾 <span id="dash-lbl-save-preset">Als Preset speichern</span></button>
|
||||
<button id="dash-reset-btn" onclick="resetDashLayout()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">↺ <span id="dash-lbl-reset">Zurücksetzen</span></button>
|
||||
<button id="dash-edit-btn" onclick="toggleDashEdit()" style="padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">🖉 <span id="dash-lbl-edit">Dashboard anpassen</span></button>
|
||||
</div>
|
||||
<div class="grid-stack" id="dash-grid">
|
||||
<!-- Kamera -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card" id="card-camera" data-card="camera" gs-w="12" gs-h="7">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
|
||||
<div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
@@ -152,7 +167,7 @@
|
||||
<div class="cam-wrap" id="cam-wrap">
|
||||
<div class="cam-placeholder" id="cam-placeholder"><span id="cam-placeholder-txt">📷 Kamera nicht gestartet</span></div>
|
||||
<div class="cam-spinner" id="cam-spinner"></div>
|
||||
<img id="cam-img" style="display:none;width:100%;height:auto" alt="Kamera">
|
||||
<img id="cam-img" draggable="false" style="display:none;width:100%;height:auto" alt="Kamera">
|
||||
<div class="cam-overlay" id="cam-overlay" style="display:none">
|
||||
<div style="font-size:12px;color:#fff" id="cam-fname"></div>
|
||||
</div>
|
||||
@@ -162,7 +177,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Fortschritt -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card" id="card-progress" data-card="progress" gs-w="12" gs-h="6">
|
||||
<div class="card-title"><span>◉</span> <span id="d-card-progress">Fortschritt</span></div>
|
||||
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
|
||||
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
|
||||
@@ -208,7 +223,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Temperatursteuerung + Verlauf -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card" id="card-temps" data-card="temps" gs-w="6" gs-h="7">
|
||||
<div class="card-title"><span>⊙</span> <span id="d-card-temps">Temperaturen</span></div>
|
||||
<div class="temp-card-inner">
|
||||
<div class="temp-block">
|
||||
@@ -251,43 +266,55 @@
|
||||
</div>
|
||||
|
||||
<!-- Achsensteuerung -->
|
||||
<div class="card">
|
||||
<div class="card-title"><span>✛</span> <span id="ptitle-motion-xy">XY-Achsen</span></div>
|
||||
<div class="joypad">
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(1,1,getStep())" title="Y+">▲</button>
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(0,-1,getStep())" title="X−">◀</button>
|
||||
<button class="joy home" onclick="homeAll()" title="Home All">⌂</button>
|
||||
<button class="joy" onclick="move(0,1,getStep())" title="X+">▶</button>
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(1,-1,getStep())" title="Y−">▼</button>
|
||||
<div></div>
|
||||
<div class="card" id="card-motion" data-card="motion" gs-w="6" gs-h="7">
|
||||
<div class="card-title"><span>✛</span> <span id="ptitle-motion-xy">Achsensteuerung</span></div>
|
||||
<div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
|
||||
<!-- XY -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:6px">
|
||||
<div class="joypad">
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(1,1,getStep())" title="Y+">▲</button>
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(0,-1,getStep())" title="X−">◀</button>
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(0,1,getStep())" title="X+">▶</button>
|
||||
<div></div>
|
||||
<button class="joy" onclick="move(1,-1,getStep())" title="Y−">▼</button>
|
||||
<div></div>
|
||||
</div>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt);width:100%" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
|
||||
</div>
|
||||
<!-- Z -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:6px">
|
||||
<div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)">
|
||||
<button class="joy" onclick="move(2,1,getStep())" title="Z+">▲</button>
|
||||
<button class="joy" onclick="move(2,-1,getStep())" title="Z−">▼</button>
|
||||
</div>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt);width:100%" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-btns">
|
||||
<button class="step-btn" onclick="setStep(this,0.1)">0.1</button>
|
||||
<button class="step-btn active" onclick="setStep(this,1)">1</button>
|
||||
<button class="step-btn" onclick="setStep(this,5)">5</button>
|
||||
<button class="step-btn" onclick="setStep(this,10)">10 mm</button>
|
||||
<!-- Einheitliche Step-Size -->
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-top:10px;flex-wrap:wrap">
|
||||
<div class="step-btns" style="margin-top:0">
|
||||
<button class="step-btn" onclick="setStep(this,0.1)">0.1</button>
|
||||
<button class="step-btn active" onclick="setStep(this,1)">1</button>
|
||||
<button class="step-btn" onclick="setStep(this,5)">5</button>
|
||||
<button class="step-btn" onclick="setStep(this,10)">10</button>
|
||||
</div>
|
||||
<input id="step-custom" type="number" min="0.1" max="260" step="0.1"
|
||||
placeholder="mm"
|
||||
style="width:60px;padding:4px 6px;font-size:12px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);text-align:center"
|
||||
oninput="setStepCustom(this)">
|
||||
</div>
|
||||
<div class="home-btns">
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
|
||||
<!-- Globale Befehle zentriert -->
|
||||
<div style="display:flex;justify-content:center;gap:8px;margin-top:10px">
|
||||
<button class="btn btn-sm btn-accent" onclick="homeAll()"><span class="lbl-home-all">Home All</span></button>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="disableMotors()"><span class="lbl-disable-motors">Motors Off</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title"><span>↕</span> <span id="ptitle-motion-z">Z-Achse</span></div>
|
||||
<div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)">
|
||||
<button class="joy" onclick="move(2,1,getStep())" title="Z+">▲</button>
|
||||
<button class="joy" onclick="move(2,-1,getStep())" title="Z−">▼</button>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:8px;font-size:12px;color:var(--txt2)"><span class="lbl-step">Schrittweite:</span> <span id="step-display">1</span> mm</div>
|
||||
</div>
|
||||
|
||||
<!-- Print Speed -->
|
||||
<div class="card">
|
||||
<div class="card" id="card-speed" data-card="speed" gs-w="6" gs-h="3">
|
||||
<div class="card-title"><span>🏎</span> <span id="d-card-speed">Druckgeschwindigkeit</span></div>
|
||||
<div style="display:flex;gap:8px;margin-top:4px">
|
||||
<button class="spd-btn" id="d-spd-1" onclick="setSpeed(1)">
|
||||
@@ -309,7 +336,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Lüfter -->
|
||||
<div class="card">
|
||||
<div class="card" id="card-fan" data-card="fan" gs-w="6" gs-h="3">
|
||||
<div class="card-title"><span>🌀</span> <span id="d-card-lightfan">Lüfter</span></div>
|
||||
<div class="slider-row">
|
||||
<input type="range" class="slider" min="0" max="100" value="0" id="d-fan" oninput="document.getElementById('d-fan-val').textContent=this.value" onchange="setFan()">
|
||||
@@ -324,18 +351,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="d-ace-dry-wrap" style="display:none">
|
||||
<div id="d-ace-dry-grid" style="display:contents"></div>
|
||||
</div>
|
||||
|
||||
<!-- AMS -->
|
||||
<div class="card" style="grid-column:1/-1" id="d-ams-card">
|
||||
<div class="card" id="d-ams-card" data-card="ams" gs-w="12" gs-h="4">
|
||||
<div class="card-title"><span>◫</span> <span id="d-card-ams">Filament</span></div>
|
||||
<div class="ams-slots" id="ams-slots">
|
||||
<div style="grid-column:1/-1;text-align:center;color:var(--txt2);padding:20px" id="ams-no-data">Keine AMS-Daten empfangen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ACE-Trocknung: außerhalb des GridStack-Grids, per Drucker-State eingeblendet -->
|
||||
<div id="d-ace-dry-wrap" class="grid" style="display:none;margin-top:16px">
|
||||
<div id="d-ace-dry-grid" style="display:contents"></div>
|
||||
</div>
|
||||
<div id="dash-hidden-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ CONSOLE ═══ -->
|
||||
@@ -495,6 +523,10 @@
|
||||
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
|
||||
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-vibration-compensation" style="width:auto;margin:0">
|
||||
<label id="lbl-vibration-compensation" style="margin:0;cursor:pointer" for="s-vibration-compensation">Resonance compensation before print</label>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label id="lbl-file-ready-mode">Nach Upload: Druckstart-Verhalten</label>
|
||||
<select id="s-file-ready-mode">
|
||||
@@ -536,6 +568,10 @@
|
||||
<input type="number" id="s-poll-interval" min="1" max="60" step="1" placeholder="3" oninput="onPollIntervalInput()">
|
||||
<small style="color:var(--txt2)" id="lbl-poll-hint">Wie oft die Bridge den Drucker-Status abfragt</small>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-verbose-http-log" style="width:auto;margin:0">
|
||||
<label id="lbl-verbose-http-log" style="margin:0;cursor:pointer" for="s-verbose-http-log">Log every HTTP request (verbose)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -570,6 +606,12 @@
|
||||
<div id="visible-vendors-list" style="max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;padding:8px"></div>
|
||||
<button class="btn btn-sm" style="background:var(--accent);color:#fff;margin-top:8px" onclick="saveVisibleVendors()"><span id="lbl-visible-vendors-save">Auswahl speichern</span></button>
|
||||
</div>
|
||||
<div class="card" id="spoolman-slot-card" style="display:none">
|
||||
<div class="card-title"><span>🧵</span> <span id="lbl-spoolman-slot-assign">Spoolman — Slot-Zuordnung</span></div>
|
||||
<div style="font-size:11px;color:var(--txt2);margin-bottom:10px" id="lbl-spoolman-slot-hint">Spoolman-Spool pro AMS-Slot zuweisen. Der Verbrauch wird beim Druck automatisch gemeldet.</div>
|
||||
<div id="spoolman-slot-rows" style="display:flex;flex-direction:column;gap:8px"></div>
|
||||
<button class="btn btn-sm" style="background:var(--accent);color:#fff;margin-top:10px" onclick="saveSpoolmanSlots()"><span id="lbl-spoolman-slot-save">Zuordnung speichern</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Integrationen -->
|
||||
@@ -582,7 +624,7 @@
|
||||
<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=aus)</label>
|
||||
<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)">
|
||||
@@ -655,6 +697,7 @@
|
||||
<button onclick="closeFilamentDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)">✕</button>
|
||||
</div>
|
||||
<p id="fd-slots-hint" style="font-size:12px;color:var(--txt2);margin-bottom:10px">GCode-Kanal → AMS-Slot zuweisen:</p>
|
||||
<div id="fd-mismatch-warn" style="display:none;margin-bottom:10px;padding:8px 10px;background:rgba(255,160,0,.12);border:1px solid rgba(255,160,0,.4);border-radius:8px;font-size:11px;color:#ffa000"></div>
|
||||
<div id="fd-slots" style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px"></div>
|
||||
<div id="fd-objects-section" style="display:none;margin-bottom:16px">
|
||||
<button type="button" id="fd-objects-toggle" onclick="toggleFdObjects()"
|
||||
|
||||
9
web/themes/default/lib/gridstack-all.min.js
vendored
Normal file
9
web/themes/default/lib/gridstack-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/themes/default/lib/gridstack.min.css
vendored
Normal file
1
web/themes/default/lib/gridstack.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.grid-stack{position:relative}.grid-stack-rtl{direction:ltr}.grid-stack-rtl>.grid-stack-item{direction:rtl}.grid-stack-placeholder>.placeholder-content{background-color:rgba(0,0,0,.1);margin:0;position:absolute;width:auto;z-index:0!important}.grid-stack>.grid-stack-item{position:absolute;padding:0}.grid-stack>.grid-stack-item>.grid-stack-item-content{margin:0;position:absolute;width:auto;overflow-x:hidden;overflow-y:auto}.grid-stack>.grid-stack-item.size-to-content:not(.size-to-content-max)>.grid-stack-item-content{overflow-y:hidden}.grid-stack-item>.ui-resizable-handle{position:absolute;font-size:.1px;display:block;-ms-touch-action:none;touch-action:none}.grid-stack-item.ui-resizable-autohide>.ui-resizable-handle,.grid-stack-item.ui-resizable-disabled>.ui-resizable-handle{display:none}.grid-stack-item>.ui-resizable-ne,.grid-stack-item>.ui-resizable-nw,.grid-stack-item>.ui-resizable-se,.grid-stack-item>.ui-resizable-sw{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="%23666" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 20 20"><path d="m10 3 2 2H8l2-2v14l-2-2h4l-2 2"/></svg>');background-repeat:no-repeat;background-position:center}.grid-stack-item>.ui-resizable-ne{transform:translate(0,10px) rotate(45deg)}.grid-stack-item>.ui-resizable-sw{transform:rotate(45deg)}.grid-stack-item>.ui-resizable-nw{transform:translate(0,10px) rotate(-45deg)}.grid-stack-item>.ui-resizable-se{transform:rotate(-45deg)}.grid-stack-item>.ui-resizable-nw{cursor:nw-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-n{cursor:n-resize;height:10px;top:0;left:25px;right:25px}.grid-stack-item>.ui-resizable-ne{cursor:ne-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-e{cursor:e-resize;width:10px;top:15px;bottom:15px}.grid-stack-item>.ui-resizable-se{cursor:se-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-s{cursor:s-resize;height:10px;left:25px;bottom:0;right:25px}.grid-stack-item>.ui-resizable-sw{cursor:sw-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-w{cursor:w-resize;width:10px;top:15px;bottom:15px}.grid-stack-item.ui-draggable-dragging>.ui-resizable-handle{display:none!important}.grid-stack-item.ui-draggable-dragging{will-change:left,top;cursor:move}.grid-stack-item.ui-resizable-resizing{will-change:width,height}.ui-draggable-dragging,.ui-resizable-resizing{z-index:10000}.ui-draggable-dragging>.grid-stack-item-content,.ui-resizable-resizing>.grid-stack-item-content{box-shadow:1px 4px 6px rgba(0,0,0,.2);opacity:.8}.grid-stack-animate,.grid-stack-animate .grid-stack-item{transition:left .3s,top .3s,height .3s,width .3s}.grid-stack-animate .grid-stack-item.grid-stack-placeholder,.grid-stack-animate .grid-stack-item.ui-draggable-dragging,.grid-stack-animate .grid-stack-item.ui-resizable-resizing{transition:left 0s,top 0s,height 0s,width 0s}.grid-stack>.grid-stack-item[gs-y="0"]{top:0}.grid-stack>.grid-stack-item[gs-x="0"]{left:0}.gs-12>.grid-stack-item{width:8.333%}.gs-12>.grid-stack-item[gs-x="1"]{left:8.333%}.gs-12>.grid-stack-item[gs-w="2"]{width:16.667%}.gs-12>.grid-stack-item[gs-x="2"]{left:16.667%}.gs-12>.grid-stack-item[gs-w="3"]{width:25%}.gs-12>.grid-stack-item[gs-x="3"]{left:25%}.gs-12>.grid-stack-item[gs-w="4"]{width:33.333%}.gs-12>.grid-stack-item[gs-x="4"]{left:33.333%}.gs-12>.grid-stack-item[gs-w="5"]{width:41.667%}.gs-12>.grid-stack-item[gs-x="5"]{left:41.667%}.gs-12>.grid-stack-item[gs-w="6"]{width:50%}.gs-12>.grid-stack-item[gs-x="6"]{left:50%}.gs-12>.grid-stack-item[gs-w="7"]{width:58.333%}.gs-12>.grid-stack-item[gs-x="7"]{left:58.333%}.gs-12>.grid-stack-item[gs-w="8"]{width:66.667%}.gs-12>.grid-stack-item[gs-x="8"]{left:66.667%}.gs-12>.grid-stack-item[gs-w="9"]{width:75%}.gs-12>.grid-stack-item[gs-x="9"]{left:75%}.gs-12>.grid-stack-item[gs-w="10"]{width:83.333%}.gs-12>.grid-stack-item[gs-x="10"]{left:83.333%}.gs-12>.grid-stack-item[gs-w="11"]{width:91.667%}.gs-12>.grid-stack-item[gs-x="11"]{left:91.667%}.gs-12>.grid-stack-item[gs-w="12"]{width:100%}.gs-1>.grid-stack-item{width:100%}
|
||||
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}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
/* select/option-Farben explizit setzen — OrcaSlicers Device-Tab-Webview erbt
|
||||
sie sonst nicht und rendert weiße Schrift auf weißem Grund (Issue #29). */
|
||||
select{background:var(--raised)!important;color:var(--txt)!important}
|
||||
sie sonst nicht und rendert weiße Schrift auf weißem Grund (Issue #29).
|
||||
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}
|
||||
|
||||
/* 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{background:var(--card);border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;gap:12px;padding:0 20px;height:52px;
|
||||
@@ -59,18 +95,63 @@ main{flex:1;overflow-y:auto;padding:20px}
|
||||
|
||||
/* ── CARD ── */
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;
|
||||
padding:18px;transition:box-shadow .15s,transform .15s}
|
||||
padding:18px;transition:box-shadow .15s,transform .15s;container-type:inline-size}
|
||||
.card:hover{box-shadow:0 4px 20px rgba(0,0,0,.3);transform:translateY(-1px)}
|
||||
.card-title{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--txt2);
|
||||
margin-bottom:14px;display:flex;align-items:center;gap:8px}
|
||||
.card-title span{font-size:14px}
|
||||
|
||||
/* ── DASHBOARD FREE GRID (GridStack) ── */
|
||||
/* .grid-stack-item-content is already position:absolute + fills the cell
|
||||
(GridStack's own CSS). The card inside just needs to fill that box — it
|
||||
must NOT be position:absolute itself, or it can sit above/intercept
|
||||
GridStack's resize-handle hit area and drag listeners. */
|
||||
/* Doc pattern: the card fills its cell; content scrolls if the user resizes
|
||||
the cell smaller than the content needs. */
|
||||
.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow:auto;box-sizing:border-box}
|
||||
/* .card:hover{transform:translateY(-1px)} creates a new containing block right
|
||||
as the user starts dragging, throwing off GridStack's position math. Kill
|
||||
the hover transform for dashboard cards specifically. */
|
||||
.grid-stack-item-content>.card:hover{transform:none}
|
||||
/* Edit mode: dashed outline + grab cursor on each item */
|
||||
#dash-grid.editing .grid-stack-item-content>.card{cursor:grab;
|
||||
outline:1px dashed var(--accent);outline-offset:-1px;user-select:none}
|
||||
/* GridStack placeholder styled to theme */
|
||||
.grid-stack>.grid-stack-placeholder>.placeholder-content{
|
||||
background:rgba(120,150,255,.12);border:1px dashed var(--accent);border-radius:12px}
|
||||
/* Resize handles only visible in edit mode */
|
||||
#dash-grid:not(.editing) .ui-resizable-handle{display:none!important}
|
||||
/* Per-card controls (hide button) */
|
||||
.dash-card-controls{display:none;position:absolute;top:8px;right:8px;gap:4px;z-index:6}
|
||||
#dash-grid.editing .dash-card-controls{display:flex}
|
||||
.dash-card-ctrl-btn{width:24px;height:24px;border-radius:6px;border:1px solid var(--border);
|
||||
background:var(--raised);color:var(--txt2);cursor:pointer;font-size:12px;
|
||||
display:flex;align-items:center;justify-content:center;line-height:1}
|
||||
.dash-card-ctrl-btn:hover{color:var(--accent);border-color:var(--accent)}
|
||||
#dash-hidden-bar{display:none;flex-wrap:wrap;gap:8px;margin-top:12px;padding:10px;
|
||||
border:1px dashed var(--border);border-radius:10px}
|
||||
#dash-hidden-bar.show{display:flex}
|
||||
.dash-hidden-chip{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:12px;
|
||||
background:var(--raised);border:1px solid var(--border);border-radius:20px;color:var(--txt2)}
|
||||
.dash-hidden-chip button{background:none;border:none;color:var(--accent);cursor:pointer;font-size:12px;padding:0}
|
||||
|
||||
@media(max-width:768px){
|
||||
#dash-toolbar{display:none}
|
||||
}
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero{grid-column:1/-1;display:grid;grid-template-columns:1fr 320px;gap:16px}
|
||||
@media(max-width:900px){.hero{grid-template-columns:1fr}}
|
||||
.cam-wrap{background:#0a0a0e;border-radius:10px;overflow:hidden;
|
||||
min-height:180px;max-height:320px;display:flex;align-items:center;justify-content:center;position:relative}
|
||||
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain}
|
||||
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain;
|
||||
-webkit-user-drag:none;user-select:none}
|
||||
/* Inside the dashboard grid the camera card can be resized taller than the
|
||||
default 320px cap — flex column: title row keeps its height, cam view
|
||||
flexes to fill whatever cell height the user chose. */
|
||||
.grid-stack-item-content>#card-camera{display:flex;flex-direction:column}
|
||||
.grid-stack-item-content .cam-wrap{flex:1;min-height:0;max-height:none}
|
||||
.grid-stack-item-content .cam-wrap img,.grid-stack-item-content .cam-wrap video{max-height:100%;height:100%}
|
||||
.cam-placeholder{color:var(--txt2);font-size:13px;text-align:center;padding:20px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.cam-spinner{width:40px;height:40px;border:3px solid rgba(255,255,255,.15);
|
||||
@@ -125,6 +206,12 @@ main{flex:1;overflow-y:auto;padding:20px}
|
||||
/* ── TEMPS ── */
|
||||
.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
@container (max-width:340px){
|
||||
.temp-card-inner{grid-template-columns:1fr}
|
||||
.temp-val{font-size:24px}
|
||||
.temp-edit{flex-wrap:wrap}
|
||||
.temp-input{width:100%}
|
||||
}
|
||||
.temp-block{background:var(--raised);border-radius:10px;padding:14px;position:relative}
|
||||
.temp-label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:6px}
|
||||
.temp-row{display:flex;align-items:baseline;gap:6px}
|
||||
@@ -265,6 +352,8 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:
|
||||
.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%}
|
||||
.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-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}
|
||||
|
||||
@@ -121,9 +121,10 @@
|
||||
"lbl_feed": "Einziehen",
|
||||
"lbl_layers": "Layer",
|
||||
"lbl_light": "💡 Licht",
|
||||
"lbl_pause_reason": "Druck pausiert:",
|
||||
"lbl_remaining": "Restzeit:",
|
||||
"lbl_slicer_time": "Slicer-Schätzung:",
|
||||
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=aus)",
|
||||
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=Druckende)",
|
||||
"lbl_spoolman_url": "Server-URL",
|
||||
"lbl_unload": "Ausziehen",
|
||||
"lbl_zpos": "Z (mm)",
|
||||
@@ -189,8 +190,7 @@
|
||||
"panel_extras_camera": "Kamera",
|
||||
"panel_extras_fan": "Lüfter",
|
||||
"panel_extras_light": "Licht",
|
||||
"panel_motion_xy": "XY-Achsen",
|
||||
"panel_motion_z": "Z-Achse",
|
||||
"panel_motion_xy": "Achsensteuerung",
|
||||
"panel_print_btn_cancel": "✕ Abbrechen",
|
||||
"panel_print_btn_pause": "⏸ Pause",
|
||||
"panel_print_btn_resume": "▶ Fortsetzen",
|
||||
@@ -213,6 +213,7 @@
|
||||
"progress_action_slots": "Slots zuordnen",
|
||||
"settings_auto_leveling": "Auto-Leveling vor Druck",
|
||||
"settings_auto_leveling_label": "Auto-Leveling vor dem Druck",
|
||||
"settings_vibration_compensation": "Resonanzkompensation",
|
||||
"settings_btn_tooltip": "Einstellungen",
|
||||
"settings_camera_on_print": "Kamera bei Druckstart einschalten",
|
||||
"settings_cat_connection": "Verbindung",
|
||||
@@ -246,6 +247,19 @@
|
||||
"settings_password": "MQTT-Passwort",
|
||||
"settings_poll": "Poll-Intervall (Sekunden)",
|
||||
"settings_poll_interval_hint": "Wie oft die Bridge den Druckerstatus abfragt",
|
||||
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
|
||||
"dash_edit": "Dashboard anpassen",
|
||||
"dash_done": "Fertig",
|
||||
"dash_reset": "Zurücksetzen",
|
||||
"dash_preset_standard": "Standard",
|
||||
"dash_preset_wide89": "Desktop breit",
|
||||
"dash_hidden_cards": "Ausgeblendete Karten",
|
||||
"dash_show": "Einblenden",
|
||||
"dash_hide": "Ausblenden",
|
||||
"dash_toggle_width": "Breite umschalten",
|
||||
"dash_save_preset": "Als Preset speichern",
|
||||
"dash_preset_name_prompt": "Preset-Name:",
|
||||
"dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
|
||||
"settings_poll_interval_label": "Poll-Intervall (Sekunden)",
|
||||
"settings_print": "Druckeinstellungen",
|
||||
"settings_printer_ip": "Drucker-IP",
|
||||
@@ -325,5 +339,6 @@
|
||||
"update_docker_copied": "Kopiert! Ausführen: docker compose pull && docker compose up -d",
|
||||
"update_error": "Fehler",
|
||||
"update_none": "Bereits aktuell",
|
||||
"update_restarting": "Starte neu..."
|
||||
}
|
||||
"update_restarting": "Starte neu...",
|
||||
"slot_copy_from": "Farbe von Slot kopieren…"
|
||||
}
|
||||
@@ -121,9 +121,10 @@
|
||||
"lbl_feed": "Load",
|
||||
"lbl_layers": "Layer",
|
||||
"lbl_light": "💡 Light",
|
||||
"lbl_pause_reason": "Print paused:",
|
||||
"lbl_remaining": "Remaining:",
|
||||
"lbl_slicer_time": "Slicer estimate:",
|
||||
"lbl_spoolman_sync_rate": "Sync rate (s, 0=off)",
|
||||
"lbl_spoolman_sync_rate": "Sync rate (s, 0=end of print)",
|
||||
"lbl_spoolman_url": "Server URL",
|
||||
"lbl_unload": "Unload",
|
||||
"lbl_zpos": "Z (mm)",
|
||||
@@ -189,8 +190,7 @@
|
||||
"panel_extras_camera": "Camera",
|
||||
"panel_extras_fan": "Fan",
|
||||
"panel_extras_light": "Light",
|
||||
"panel_motion_xy": "XY Axes",
|
||||
"panel_motion_z": "Z Axis",
|
||||
"panel_motion_xy": "Axes Control",
|
||||
"panel_print_btn_cancel": "✕ Cancel",
|
||||
"panel_print_btn_pause": "⏸ Pause",
|
||||
"panel_print_btn_resume": "▶ Resume",
|
||||
@@ -213,6 +213,7 @@
|
||||
"progress_action_slots": "Map slots",
|
||||
"settings_auto_leveling": "Auto-Leveling Default",
|
||||
"settings_auto_leveling_label": "Auto-Leveling before print",
|
||||
"settings_vibration_compensation": "Resonance Compensation",
|
||||
"settings_btn_tooltip": "Settings",
|
||||
"settings_camera_on_print": "Turn camera on at print start",
|
||||
"settings_cat_connection": "Connection",
|
||||
@@ -246,6 +247,19 @@
|
||||
"settings_password": "MQTT Password",
|
||||
"settings_poll": "Poll Interval (seconds)",
|
||||
"settings_poll_interval_hint": "How often the bridge queries printer status",
|
||||
"settings_verbose_http_log": "Log every HTTP request (verbose)",
|
||||
"dash_edit": "Customize dashboard",
|
||||
"dash_done": "Done",
|
||||
"dash_reset": "Reset",
|
||||
"dash_preset_standard": "Standard",
|
||||
"dash_preset_wide89": "Wide desktop",
|
||||
"dash_hidden_cards": "Hidden cards",
|
||||
"dash_show": "Show",
|
||||
"dash_hide": "Hide",
|
||||
"dash_toggle_width": "Toggle width",
|
||||
"dash_save_preset": "Save as preset",
|
||||
"dash_preset_name_prompt": "Preset name:",
|
||||
"dash_preset_delete_confirm": "Delete preset \"{name}\"?",
|
||||
"settings_poll_interval_label": "Poll Interval (seconds)",
|
||||
"settings_print": "Print Settings",
|
||||
"settings_printer_ip": "Printer IP",
|
||||
@@ -325,5 +339,6 @@
|
||||
"update_docker_copied": "Copied! Run: docker compose pull && docker compose up -d",
|
||||
"update_error": "Error",
|
||||
"update_none": "Already up to date",
|
||||
"update_restarting": "Restarting..."
|
||||
}
|
||||
"update_restarting": "Restarting...",
|
||||
"slot_copy_from": "Copy color from slot…"
|
||||
}
|
||||
@@ -121,9 +121,10 @@
|
||||
"lbl_feed": "Cargar",
|
||||
"lbl_layers": "Capa",
|
||||
"lbl_light": "💡 Luz",
|
||||
"lbl_pause_reason": "Impresión pausada:",
|
||||
"lbl_remaining": "Restante:",
|
||||
"lbl_slicer_time": "Estimación del slicer:",
|
||||
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=desact.)",
|
||||
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=fin impresión)",
|
||||
"lbl_spoolman_url": "URL del servidor",
|
||||
"lbl_unload": "Descargar",
|
||||
"lbl_zpos": "Z (mm)",
|
||||
@@ -189,8 +190,7 @@
|
||||
"panel_extras_camera": "Cámara",
|
||||
"panel_extras_fan": "Ventilador",
|
||||
"panel_extras_light": "Luz",
|
||||
"panel_motion_xy": "Ejes XY",
|
||||
"panel_motion_z": "Eje Z",
|
||||
"panel_motion_xy": "Control de Ejes",
|
||||
"panel_print_btn_cancel": "✕ Cancelar",
|
||||
"panel_print_btn_pause": "⏸ Pausa",
|
||||
"panel_print_btn_resume": "▶ Reanudar",
|
||||
@@ -213,6 +213,7 @@
|
||||
"progress_action_slots": "Asignar ranuras",
|
||||
"settings_auto_leveling": "Autonivelado antes de imprimir",
|
||||
"settings_auto_leveling_label": "Autonivelado antes de imprimir",
|
||||
"settings_vibration_compensation": "Compensación de resonancia",
|
||||
"settings_btn_tooltip": "Ajustes",
|
||||
"settings_camera_on_print": "Encender cámara al iniciar impresión",
|
||||
"settings_cat_connection": "Conexión",
|
||||
@@ -246,6 +247,19 @@
|
||||
"settings_password": "Contraseña MQTT",
|
||||
"settings_poll": "Intervalo de sondeo (segundos)",
|
||||
"settings_poll_interval_hint": "Con qué frecuencia el bridge consulta el estado de la impresora",
|
||||
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
|
||||
"dash_edit": "Personalizar panel",
|
||||
"dash_done": "Listo",
|
||||
"dash_reset": "Restablecer",
|
||||
"dash_preset_standard": "Estándar",
|
||||
"dash_preset_wide89": "Escritorio ancho",
|
||||
"dash_hidden_cards": "Tarjetas ocultas",
|
||||
"dash_show": "Mostrar",
|
||||
"dash_hide": "Ocultar",
|
||||
"dash_toggle_width": "Cambiar ancho",
|
||||
"dash_save_preset": "Guardar como preset",
|
||||
"dash_preset_name_prompt": "Nombre del preset:",
|
||||
"dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
|
||||
"settings_poll_interval_label": "Intervalo de sondeo (segundos)",
|
||||
"settings_print": "Ajustes de impresión",
|
||||
"settings_printer_ip": "IP de impresora",
|
||||
@@ -325,5 +339,6 @@
|
||||
"update_docker_copied": "Copiado. Ejecutar: docker compose pull && docker compose up -d",
|
||||
"update_error": "Error",
|
||||
"update_none": "Ya actualizado",
|
||||
"update_restarting": "Reiniciando..."
|
||||
}
|
||||
"update_restarting": "Reiniciando...",
|
||||
"slot_copy_from": "Copiar color del slot…"
|
||||
}
|
||||
@@ -121,9 +121,10 @@
|
||||
"lbl_feed": "Charger",
|
||||
"lbl_layers": "Couche",
|
||||
"lbl_light": "💡 Lumière",
|
||||
"lbl_pause_reason": "Impression en pause :",
|
||||
"lbl_remaining": "Restant :",
|
||||
"lbl_slicer_time": "Estimation slicer :",
|
||||
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=désact.)",
|
||||
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=fin impression)",
|
||||
"lbl_spoolman_url": "URL du serveur",
|
||||
"lbl_unload": "Décharger",
|
||||
"lbl_zpos": "Z (mm)",
|
||||
@@ -189,8 +190,7 @@
|
||||
"panel_extras_camera": "Caméra",
|
||||
"panel_extras_fan": "Ventilateur",
|
||||
"panel_extras_light": "Lumière",
|
||||
"panel_motion_xy": "Axes XY",
|
||||
"panel_motion_z": "Axe Z",
|
||||
"panel_motion_xy": "Contrôle des Axes",
|
||||
"panel_print_btn_cancel": "✕ Annuler",
|
||||
"panel_print_btn_pause": "⏸ Pause",
|
||||
"panel_print_btn_resume": "▶ Reprendre",
|
||||
@@ -325,5 +325,6 @@
|
||||
"update_docker_copied": "Copié ! Exécuter : docker compose pull && docker compose up -d",
|
||||
"update_error": "Erreur",
|
||||
"update_none": "Déjà à jour",
|
||||
"update_restarting": "Redémarrage…"
|
||||
}
|
||||
"update_restarting": "Redémarrage…",
|
||||
"slot_copy_from": "Copier la couleur du slot…"
|
||||
}
|
||||
@@ -121,9 +121,10 @@
|
||||
"lbl_feed": "Carica",
|
||||
"lbl_layers": "Layer",
|
||||
"lbl_light": "💡 Luce",
|
||||
"lbl_pause_reason": "Stampa in pausa:",
|
||||
"lbl_remaining": "Rimanente:",
|
||||
"lbl_slicer_time": "Stima slicer:",
|
||||
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=disatt.)",
|
||||
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=fine stampa)",
|
||||
"lbl_spoolman_url": "URL server",
|
||||
"lbl_unload": "Rimuovi",
|
||||
"lbl_zpos": "Z (mm)",
|
||||
@@ -189,8 +190,7 @@
|
||||
"panel_extras_camera": "Camera",
|
||||
"panel_extras_fan": "Ventola",
|
||||
"panel_extras_light": "Luce",
|
||||
"panel_motion_xy": "Assi XY",
|
||||
"panel_motion_z": "Asse Z",
|
||||
"panel_motion_xy": "Controllo Assi",
|
||||
"panel_print_btn_cancel": "✕ Annulla",
|
||||
"panel_print_btn_pause": "⏸ Pausa",
|
||||
"panel_print_btn_resume": "▶ Riprendi",
|
||||
@@ -325,5 +325,6 @@
|
||||
"update_docker_copied": "Copiato! Eseguire: docker compose pull && docker compose up -d",
|
||||
"update_error": "Errore",
|
||||
"update_none": "Già aggiornato",
|
||||
"update_restarting": "Riavvio in corso..."
|
||||
}
|
||||
"update_restarting": "Riavvio in corso...",
|
||||
"slot_copy_from": "Copia colore dallo slot…"
|
||||
}
|
||||
@@ -121,9 +121,10 @@
|
||||
"lbl_feed": "进料",
|
||||
"lbl_layers": "层",
|
||||
"lbl_light": "💡 灯光",
|
||||
"lbl_pause_reason": "打印已暂停:",
|
||||
"lbl_remaining": "剩余时间:",
|
||||
"lbl_slicer_time": "切片预估:",
|
||||
"lbl_spoolman_sync_rate": "同步频率(秒,0=关闭)",
|
||||
"lbl_spoolman_sync_rate": "同步频率(秒,0=打印结束)",
|
||||
"lbl_spoolman_url": "服务器地址",
|
||||
"lbl_unload": "退料",
|
||||
"lbl_zpos": "Z (mm)",
|
||||
@@ -189,8 +190,7 @@
|
||||
"panel_extras_camera": "相机",
|
||||
"panel_extras_fan": "风扇",
|
||||
"panel_extras_light": "灯光",
|
||||
"panel_motion_xy": "XY 轴",
|
||||
"panel_motion_z": "Z 轴",
|
||||
"panel_motion_xy": "轴控制",
|
||||
"panel_print_btn_cancel": "✕ 取消",
|
||||
"panel_print_btn_pause": "⏸ 暂停",
|
||||
"panel_print_btn_resume": "▶ 继续",
|
||||
@@ -213,6 +213,7 @@
|
||||
"progress_action_slots": "分配槽位",
|
||||
"settings_auto_leveling": "打印前自动调平",
|
||||
"settings_auto_leveling_label": "打印前自动调平",
|
||||
"settings_vibration_compensation": "打印前共振补偿",
|
||||
"settings_btn_tooltip": "设置",
|
||||
"settings_camera_on_print": "打印开始时开启相机",
|
||||
"settings_cat_connection": "连接",
|
||||
@@ -246,6 +247,19 @@
|
||||
"settings_password": "MQTT 密码",
|
||||
"settings_poll": "轮询间隔(秒)",
|
||||
"settings_poll_interval_hint": "Bridge 查询打印机状态的频率",
|
||||
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
|
||||
"dash_edit": "自定义仪表盘",
|
||||
"dash_done": "完成",
|
||||
"dash_reset": "重置",
|
||||
"dash_preset_standard": "标准",
|
||||
"dash_preset_wide89": "宽屏桌面",
|
||||
"dash_hidden_cards": "隐藏的卡片",
|
||||
"dash_show": "显示",
|
||||
"dash_hide": "隐藏",
|
||||
"dash_toggle_width": "切换宽度",
|
||||
"dash_save_preset": "另存为预设",
|
||||
"dash_preset_name_prompt": "预设名称:",
|
||||
"dash_preset_delete_confirm": "删除预设 \"{name}\"?",
|
||||
"settings_poll_interval_label": "轮询间隔(秒)",
|
||||
"settings_print": "打印设置",
|
||||
"settings_printer_ip": "打印机 IP",
|
||||
@@ -325,5 +339,6 @@
|
||||
"update_docker_copied": "已复制!执行:docker compose pull && docker compose up -d",
|
||||
"update_error": "错误",
|
||||
"update_none": "已是最新版本",
|
||||
"update_restarting": "重启中..."
|
||||
}
|
||||
"update_restarting": "重启中...",
|
||||
"slot_copy_from": "从插槽复制颜色…"
|
||||
}
|
||||
Reference in New Issue
Block a user