Parcourir la source

Allow hostnames for printers in addition to IPv4 addresses (fixes #290)

Users with local DNS can now add printers using hostnames like
printer.local or my-printer.home.lan. Updated backend schema
validation, database column width, frontend form patterns/placeholders,
and i18n labels across all locales. Added integration tests for
hostname and FQDN creation plus invalid hostname rejection.
maziggy il y a 3 mois
Parent
commit
8944943981

+ 3 - 0
CHANGELOG.md

@@ -10,6 +10,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **SSDP Discovery Limitations** — Added table showing when SSDP discovery works (same LAN, dual-homed, Docker host mode) vs when manual IP entry is required (VPN, Docker bridge, port forwarding). Updated wiki, README, and website.
 - **Firewall Rules Updated** — Added port 50000-50100/tcp to all UFW, firewalld, and iptables examples for proxy mode FTP passive data.
 
+### Added
+- **Hostname Support for Printers** ([#290](https://github.com/maziggy/bambuddy/issues/290)) — Printers can now be added using hostnames (e.g., `printer.local`, `my-printer.home.lan`) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels.
+
 ### Fixed
 - **Filament Usage Charts Inflated by Quantity Multiplier** ([#229](https://github.com/maziggy/bambuddy/issues/229)) — Daily, weekly, and filament-type charts were multiplying `filament_used_grams` by print quantity, even though the value already represents the total for the entire job. A 26-object print using 126g was counted as 3,276g. Removed the erroneous multiplier from three aggregations in `FilamentTrends.tsx`.
 - **Energy Cost Shows 0.00 in "Total Consumption" Mode** ([#284](https://github.com/maziggy/bambuddy/issues/284)) — Statistics Quick Stats showed 0.00 energy cost when Energy Display Mode was set to "Total Consumption" with Home Assistant smart plugs. The `homeassistant_service` was not configured with HA URL/token before querying plug energy data, causing it to silently return nothing.

+ 1 - 1
backend/app/models/printer.py

@@ -12,7 +12,7 @@ class Printer(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100))
     serial_number: Mapped[str] = mapped_column(String(50), unique=True)
-    ip_address: Mapped[str] = mapped_column(String(45))
+    ip_address: Mapped[str] = mapped_column(String(253))
     access_code: Mapped[str] = mapped_column(String(20))
     model: Mapped[str | None] = mapped_column(String(50))
     location: Mapped[str | None] = mapped_column(String(100))  # Group/location name

+ 10 - 2
backend/app/schemas/printer.py

@@ -6,7 +6,11 @@ from pydantic import BaseModel, Field
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     serial_number: str = Field(..., min_length=1, max_length=50)
-    ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
+    ip_address: str = Field(
+        ...,
+        max_length=253,
+        pattern=r"^(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)$",
+    )
     access_code: str = Field(..., min_length=1, max_length=20)
     model: str | None = None
     location: str | None = None  # Group/location name
@@ -31,7 +35,11 @@ class PlateDetectionROI(BaseModel):
 
 class PrinterUpdate(BaseModel):
     name: str | None = None
-    ip_address: str | None = None
+    ip_address: str | None = Field(
+        default=None,
+        max_length=253,
+        pattern=r"^(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)$",
+    )
     access_code: str | None = None
     model: str | None = None
     location: str | None = None

+ 52 - 0
backend/tests/integration/test_printers_api.py

@@ -63,6 +63,58 @@ class TestPrintersAPI:
         assert result["serial_number"] == "00M09A111111111"
         assert result["model"] == "X1C"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_printer_with_hostname(self, async_client: AsyncClient):
+        """Verify printer can be created with a hostname instead of IP address."""
+        data = {
+            "name": "DNS Printer",
+            "serial_number": "00M09A555555555",
+            "ip_address": "printer.local",
+            "access_code": "12345678",
+            "model": "P1S",
+        }
+
+        response = await async_client.post("/api/v1/printers/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "DNS Printer"
+        assert result["ip_address"] == "printer.local"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_printer_with_fqdn(self, async_client: AsyncClient):
+        """Verify printer can be created with a fully qualified domain name."""
+        data = {
+            "name": "FQDN Printer",
+            "serial_number": "00M09A666666666",
+            "ip_address": "my-printer.home.lan",
+            "access_code": "12345678",
+            "model": "X1C",
+        }
+
+        response = await async_client.post("/api/v1/printers/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ip_address"] == "my-printer.home.lan"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_printer_invalid_hostname(self, async_client: AsyncClient):
+        """Verify invalid hostnames are rejected."""
+        data = {
+            "name": "Bad Printer",
+            "serial_number": "00M09A777777777",
+            "ip_address": "-invalid",
+            "access_code": "12345678",
+        }
+
+        response = await async_client.post("/api/v1/printers/", json=data)
+
+        assert response.status_code == 422
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_create_printer_duplicate_serial(self, async_client: AsyncClient, printer_factory, db_session):

+ 1 - 1
frontend/src/i18n/locales/de.ts

@@ -115,7 +115,7 @@ export default {
     deletePrinter: 'Drucker löschen',
     printerName: 'Druckername',
     serialNumber: 'Seriennummer',
-    ipAddress: 'IP-Adresse',
+    ipAddress: 'IP-Adresse / Hostname',
     accessCode: 'Zugangscode',
     model: 'Modell',
     nozzleCount: 'Düsenanzahl',

+ 1 - 1
frontend/src/i18n/locales/en.ts

@@ -115,7 +115,7 @@ export default {
     deletePrinter: 'Delete Printer',
     printerName: 'Printer Name',
     serialNumber: 'Serial Number',
-    ipAddress: 'IP Address',
+    ipAddress: 'IP Address / Hostname',
     accessCode: 'Access Code',
     model: 'Model',
     nozzleCount: 'Nozzle Count',

+ 2 - 2
frontend/src/i18n/locales/ja.ts

@@ -95,7 +95,7 @@ export default {
     deletePrinter: 'プリンターを削除',
     printerName: 'プリンター名',
     serialNumber: 'シリアル番号',
-    ipAddress: 'IPアドレス',
+    ipAddress: 'IPアドレス / ホスト名',
     accessCode: 'アクセスコード',
     model: 'モデル',
     nozzleCount: 'ノズル数',
@@ -240,7 +240,7 @@ export default {
     form: {
       name: '名前',
       namePlaceholder: 'マイプリンター',
-      ipAddress: 'IPアドレス',
+      ipAddress: 'IPアドレス / ホスト名',
       serialNumber: 'シリアル番号',
       serialCannotChange: 'シリアル番号は変更できません',
       accessCode: 'アクセスコード',

+ 4 - 4
frontend/src/pages/PrintersPage.tsx

@@ -3846,11 +3846,11 @@ function AddPrinterModal({
               <input
                 type="text"
                 required
-                pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
+                pattern="(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)"
                 className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                 value={form.ip_address}
                 onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
-                placeholder="192.168.1.100"
+                placeholder="192.168.1.100 or printer.local"
               />
             </div>
             <div>
@@ -4223,11 +4223,11 @@ function EditPrinterModal({
               <input
                 type="text"
                 required
-                pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
+                pattern="(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)"
                 className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                 value={form.ip_address}
                 onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
-                placeholder="192.168.1.100"
+                placeholder="192.168.1.100 or printer.local"
               />
             </div>
             <div>