Przeglądaj źródła

Add separate power/energy URLs and multipliers for REST smart plugs (#472)

  REST/Webhook smart plugs can now fetch power and energy data from
  individual URLs instead of requiring all values in a single status
  response. Each value falls back to the shared Status URL when no
  separate URL is set, preserving backward compatibility. Added power
  and energy multipliers for unit conversion (e.g. 0.001 for Wh→kWh).
maziggy 1 miesiąc temu
rodzic
commit
76adf70fd4

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.2.3b2] - Unreleased
 
+### Improved
+- **REST Smart Plug: Separate Power/Energy URLs and Unit Multipliers** ([#472](https://github.com/maziggy/bambuddy/issues/472)) — REST/Webhook smart plugs can now use individual URLs for power and energy data instead of requiring all values in a single status response. Each value falls back to the shared Status URL when no separate URL is configured, so existing setups work without changes. Added power and energy multipliers for unit conversion (e.g., set energy multiplier to `0.001` to convert Wh to kWh). Useful for platforms like ioBroker that expose each data point as a separate API endpoint.
+
 ### Fixed
 - **WebSocket Crash on Printers Without `fun` Field** ([#873](https://github.com/maziggy/bambuddy/issues/873)) — Connecting to printers that don't send the MQTT `fun` field (A1, P1 series, X1Plus firmware) caused a repeating `'str' object has no attribute 'get'` crash in the WebSocket handler, showing the printer as offline with missing AMS and SD card info. The developer mode probe introduced in 0.2.3b1 published an MQTT message inside `_update_state()` between overwriting `raw_data` with the full MQTT dict (where `vt_tray` is a raw dict) and restoring the previously normalized list — the `publish()` call released the GIL, letting the event loop read the un-normalized dict and iterate over string keys instead of spool dicts. Fixed by normalizing `vt_tray` dict→list in the MQTT data before assignment, and moving preserved field restoration before the probe. Added defensive normalization in `printer_state_to_dict` as a belt-and-suspenders guard.
 

+ 1 - 1
README.md

@@ -120,7 +120,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Queue Only mode (stage without auto-start)
 - Clear plate confirmation between queued prints (can be disabled in settings for farm workflows)
 - Smart plug integration (Tasmota, Home Assistant, MQTT, REST/Webhook)
-- REST smart plugs: Control any device with an HTTP API (openHAB, ioBroker, FHEM, Node-RED)
+- REST smart plugs: Control any device with an HTTP API (openHAB, ioBroker, FHEM, Node-RED) with separate power/energy URLs and unit multipliers
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
 - Energy consumption tracking (per-print kWh and cost)
 - HA energy sensor support (for plugs with separate power/energy sensors)

+ 18 - 0
backend/app/core/database.py

@@ -1610,6 +1610,24 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add separate REST power/energy URLs and multipliers
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_power_url VARCHAR(500)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_power_multiplier REAL DEFAULT 1.0"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_energy_url VARCHAR(500)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_energy_multiplier REAL DEFAULT 1.0"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Add batch_id column to print_queue for batch grouping
     try:
         await conn.execute(

+ 7 - 1
backend/app/models/smart_plug.py

@@ -62,9 +62,15 @@ class SmartPlug(Base):
     rest_status_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # GET endpoint for state
     rest_status_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path to state value
     rest_status_on_value: Mapped[str | None] = mapped_column(String(50), nullable=True)  # What value means ON
-    # Energy monitoring (optional, extracted from status response)
+    # Energy monitoring (optional — can use separate URLs or extract from status response)
+    rest_power_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Separate URL for power data
     rest_power_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path for power (watts)
+    rest_power_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Unit conversion for power
+    rest_energy_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Separate URL for energy data
     rest_energy_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path for energy (kWh)
+    rest_energy_multiplier: Mapped[float] = mapped_column(
+        Float, default=1.0
+    )  # Unit conversion (e.g., 0.001 for Wh→kWh)
 
     # Link to printer (multiple plugs/scripts can be linked to one printer)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)

+ 8 - 0
backend/app/schemas/smart_plug.py

@@ -54,8 +54,12 @@ class SmartPlugBase(BaseModel):
     rest_status_url: str | None = Field(default=None, max_length=500)
     rest_status_path: str | None = Field(default=None, max_length=200)
     rest_status_on_value: str | None = Field(default=None, max_length=50)
+    rest_power_url: str | None = Field(default=None, max_length=500)
     rest_power_path: str | None = Field(default=None, max_length=200)
+    rest_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
+    rest_energy_url: str | None = Field(default=None, max_length=500)
     rest_energy_path: str | None = Field(default=None, max_length=200)
+    rest_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
 
     printer_id: int | None = None
     enabled: bool = True
@@ -138,8 +142,12 @@ class SmartPlugUpdate(BaseModel):
     rest_status_url: str | None = None
     rest_status_path: str | None = None
     rest_status_on_value: str | None = None
+    rest_power_url: str | None = None
     rest_power_path: str | None = None
+    rest_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    rest_energy_url: str | None = None
     rest_energy_path: str | None = None
+    rest_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
     printer_id: int | None = None
     enabled: bool | None = None
     auto_on: bool | None = None

+ 30 - 16
backend/app/services/rest_smart_plug.py

@@ -191,44 +191,58 @@ class RESTSmartPlugService:
         return {"state": state, "reachable": True, "device_name": None}
 
     async def get_energy(self, plug: "SmartPlug") -> dict | None:
-        """Get energy monitoring data from the status endpoint.
+        """Get energy monitoring data.
+
+        Each value (power, energy) can come from its own URL or fall back to the shared status URL.
+        Multipliers are applied to convert units (e.g., Wh → kWh with multiplier 0.001).
 
         Returns dict with energy data or None if not available.
         """
-        if not plug.rest_status_url or (not plug.rest_power_path and not plug.rest_energy_path):
+        if not plug.rest_power_path and not plug.rest_energy_path:
             return None
 
         headers = self._parse_headers(plug.rest_headers)
-        response = await self._send_request(plug.rest_status_url, "GET", headers)
+        energy: dict[str, float | None] = {}
 
-        if response is None:
-            return None
+        power_url = plug.rest_power_url or plug.rest_status_url if plug.rest_power_path else None
+        energy_url = plug.rest_energy_url or plug.rest_status_url if plug.rest_energy_path else None
 
-        try:
-            data = response.json()
-        except Exception:
-            return None
+        # Fetch data — deduplicate when both resolve to the same URL
+        fetched: dict[str, Any] = {}
 
-        energy: dict[str, float | None] = {}
+        for url in {power_url, energy_url} - {None}:
+            fetched[url] = await self._fetch_json(url, headers)
 
-        if plug.rest_power_path:
-            raw = self._extract_json_path(data, plug.rest_power_path)
+        # Extract power value
+        if plug.rest_power_path and power_url and fetched.get(power_url) is not None:
+            raw = self._extract_json_path(fetched[power_url], plug.rest_power_path)
             if raw is not None:
                 try:
-                    energy["power"] = float(raw)
+                    energy["power"] = float(raw) * (plug.rest_power_multiplier or 1.0)
                 except (ValueError, TypeError):
                     pass
 
-        if plug.rest_energy_path:
-            raw = self._extract_json_path(data, plug.rest_energy_path)
+        # Extract energy value
+        if plug.rest_energy_path and energy_url and fetched.get(energy_url) is not None:
+            raw = self._extract_json_path(fetched[energy_url], plug.rest_energy_path)
             if raw is not None:
                 try:
-                    energy["today"] = float(raw)
+                    energy["today"] = float(raw) * (plug.rest_energy_multiplier or 1.0)
                 except (ValueError, TypeError):
                     pass
 
         return energy if energy else None
 
+    async def _fetch_json(self, url: str, headers: dict[str, str]) -> Any:
+        """Fetch a URL and parse JSON response. Returns parsed data or None."""
+        response = await self._send_request(url, "GET", headers)
+        if response is None:
+            return None
+        try:
+            return response.json()
+        except Exception:
+            return None
+
     async def test_connection(self, url: str, method: str = "GET", headers: str | None = None) -> dict:
         """Test connection to a REST endpoint.
 

+ 90 - 1
backend/tests/unit/services/test_rest_smart_plug.py

@@ -28,8 +28,12 @@ def mock_plug():
     plug.rest_status_url = "http://192.168.1.50:8080/api/plug/status"
     plug.rest_status_path = "state"
     plug.rest_status_on_value = "ON"
+    plug.rest_power_url = None
     plug.rest_power_path = "power"
+    plug.rest_power_multiplier = 1.0
+    plug.rest_energy_url = None
     plug.rest_energy_path = "energy.today"
+    plug.rest_energy_multiplier = 1.0
     return plug
 
 
@@ -181,8 +185,11 @@ class TestGetEnergy:
         assert result["today"] == 1.23
 
     @pytest.mark.asyncio
-    async def test_energy_no_status_url(self, service, mock_plug):
+    async def test_energy_no_status_url_no_separate_urls(self, service, mock_plug):
+        """No URLs at all (status=None, power_url=None, energy_url=None) → None."""
         mock_plug.rest_status_url = None
+        mock_plug.rest_power_url = None
+        mock_plug.rest_energy_url = None
         result = await service.get_energy(mock_plug)
         assert result is None
 
@@ -193,6 +200,88 @@ class TestGetEnergy:
         result = await service.get_energy(mock_plug)
         assert result is None
 
+    @pytest.mark.asyncio
+    async def test_energy_with_separate_urls(self, service, mock_plug):
+        """Power and energy fetched from different URLs."""
+        mock_plug.rest_power_url = "http://192.168.1.50:8087/power"
+        mock_plug.rest_energy_url = "http://192.168.1.50:8087/energy"
+
+        power_response = MagicMock()
+        power_response.json.return_value = {"power": 9.5}
+        energy_response = MagicMock()
+        energy_response.json.return_value = {"energy": {"today": 30947.07}}
+
+        call_count = 0
+
+        async def mock_send(url, method="GET", headers=None, body=None):
+            nonlocal call_count
+            call_count += 1
+            if "power" in url:
+                return power_response
+            return energy_response
+
+        with patch.object(service, "_send_request", side_effect=mock_send):
+            result = await service.get_energy(mock_plug)
+
+        assert call_count == 2
+        assert result["power"] == 9.5
+        assert result["today"] == 30947.07
+
+    @pytest.mark.asyncio
+    async def test_energy_with_multipliers(self, service, mock_plug):
+        """Multipliers convert units (e.g., Wh → kWh)."""
+        mock_plug.rest_energy_multiplier = 0.001  # Wh → kWh
+
+        mock_response = MagicMock()
+        mock_response.json.return_value = {"power": 9.5, "energy": {"today": 30947.07}}
+
+        with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
+            result = await service.get_energy(mock_plug)
+
+        assert result["power"] == 9.5  # No multiplier (default 1.0)
+        assert result["today"] == pytest.approx(30.94707)  # 30947.07 * 0.001
+
+    @pytest.mark.asyncio
+    async def test_energy_separate_url_falls_back_to_status(self, service, mock_plug):
+        """When no separate URL is set, falls back to status URL."""
+        mock_plug.rest_power_url = None
+        mock_plug.rest_energy_url = None
+
+        mock_response = MagicMock()
+        mock_response.json.return_value = {"power": 42.5, "energy": {"today": 1.23}}
+
+        with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
+            result = await service.get_energy(mock_plug)
+
+        assert result["power"] == 42.5
+        assert result["today"] == 1.23
+
+    @pytest.mark.asyncio
+    async def test_energy_no_urls_at_all(self, service, mock_plug):
+        """No status URL and no separate URLs → None."""
+        mock_plug.rest_status_url = None
+        mock_plug.rest_power_url = None
+        mock_plug.rest_energy_url = None
+
+        result = await service.get_energy(mock_plug)
+        assert result is None
+
+    @pytest.mark.asyncio
+    async def test_energy_deduplicates_same_url(self, service, mock_plug):
+        """When power and energy both fall back to status URL, only one HTTP request is made."""
+        mock_plug.rest_power_url = None
+        mock_plug.rest_energy_url = None
+
+        mock_response = MagicMock()
+        mock_response.json.return_value = {"power": 42.5, "energy": {"today": 1.23}}
+
+        with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response) as mock_send:
+            result = await service.get_energy(mock_plug)
+
+        assert mock_send.call_count == 1
+        assert result["power"] == 42.5
+        assert result["today"] == 1.23
+
 
 class TestTestConnection:
     @pytest.mark.asyncio

+ 12 - 0
frontend/src/api/client.ts

@@ -1111,8 +1111,12 @@ export interface SmartPlug {
   rest_status_url: string | null;
   rest_status_path: string | null;
   rest_status_on_value: string | null;
+  rest_power_url: string | null;
   rest_power_path: string | null;
+  rest_power_multiplier: number;
+  rest_energy_url: string | null;
   rest_energy_path: string | null;
+  rest_energy_multiplier: number;
   printer_id: number | null;
   enabled: boolean;
   auto_on: boolean;
@@ -1178,8 +1182,12 @@ export interface SmartPlugCreate {
   rest_status_url?: string | null;
   rest_status_path?: string | null;
   rest_status_on_value?: string | null;
+  rest_power_url?: string | null;
   rest_power_path?: string | null;
+  rest_power_multiplier?: number;
+  rest_energy_url?: string | null;
   rest_energy_path?: string | null;
+  rest_energy_multiplier?: number;
   printer_id?: number | null;
   enabled?: boolean;
   auto_on?: boolean;
@@ -1237,8 +1245,12 @@ export interface SmartPlugUpdate {
   rest_status_url?: string | null;
   rest_status_path?: string | null;
   rest_status_on_value?: string | null;
+  rest_power_url?: string | null;
   rest_power_path?: string | null;
+  rest_power_multiplier?: number;
+  rest_energy_url?: string | null;
   rest_energy_path?: string | null;
+  rest_energy_multiplier?: number;
   printer_id?: number | null;
   enabled?: boolean;
   auto_on?: boolean;

+ 55 - 0
frontend/src/components/AddSmartPlugModal.tsx

@@ -52,8 +52,12 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [restStatusUrl, setRestStatusUrl] = useState(plug?.rest_status_url || '');
   const [restStatusPath, setRestStatusPath] = useState(plug?.rest_status_path || '');
   const [restStatusOnValue, setRestStatusOnValue] = useState(plug?.rest_status_on_value || '');
+  const [restPowerUrl, setRestPowerUrl] = useState(plug?.rest_power_url || '');
   const [restPowerPath, setRestPowerPath] = useState(plug?.rest_power_path || '');
+  const [restPowerMultiplier, setRestPowerMultiplier] = useState<string>((plug?.rest_power_multiplier ?? 1).toString());
+  const [restEnergyUrl, setRestEnergyUrl] = useState(plug?.rest_energy_url || '');
   const [restEnergyPath, setRestEnergyPath] = useState(plug?.rest_energy_path || '');
+  const [restEnergyMultiplier, setRestEnergyMultiplier] = useState<string>((plug?.rest_energy_multiplier ?? 1).toString());
   // HA energy sensor entities (optional)
   const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
   const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
@@ -371,8 +375,12 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       rest_status_url: plugType === 'rest' ? (restStatusUrl.trim() || null) : null,
       rest_status_path: plugType === 'rest' ? (restStatusPath.trim() || null) : null,
       rest_status_on_value: plugType === 'rest' ? (restStatusOnValue.trim() || null) : null,
+      rest_power_url: plugType === 'rest' ? (restPowerUrl.trim() || null) : null,
       rest_power_path: plugType === 'rest' ? (restPowerPath.trim() || null) : null,
+      rest_power_multiplier: plugType === 'rest' ? (parseFloat(restPowerMultiplier) || 1) : 1,
+      rest_energy_url: plugType === 'rest' ? (restEnergyUrl.trim() || null) : null,
       rest_energy_path: plugType === 'rest' ? (restEnergyPath.trim() || null) : null,
+      rest_energy_multiplier: plugType === 'rest' ? (parseFloat(restEnergyMultiplier) || 1) : 1,
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
@@ -1236,6 +1244,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
               {/* Energy Monitoring (optional) */}
               <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
                 <p className="text-white font-medium text-sm">{t('smartPlugs.energyMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
+
+                {/* Power */}
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restPowerUrl')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></label>
+                  <input
+                    type="text"
+                    value={restPowerUrl}
+                    onChange={(e) => setRestPowerUrl(e.target.value)}
+                    placeholder={t('smartPlugs.restPowerUrlHint')}
+                    className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                  />
+                </div>
                 <div className="grid grid-cols-2 gap-3">
                   <div>
                     <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restPowerPath')}</label>
@@ -1247,6 +1267,30 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                       className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                     />
                   </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restPowerMultiplier')}</label>
+                    <input
+                      type="text"
+                      value={restPowerMultiplier}
+                      onChange={(e) => setRestPowerMultiplier(e.target.value)}
+                      placeholder="1"
+                      className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                    />
+                  </div>
+                </div>
+
+                {/* Energy */}
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyUrl')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></label>
+                  <input
+                    type="text"
+                    value={restEnergyUrl}
+                    onChange={(e) => setRestEnergyUrl(e.target.value)}
+                    placeholder={t('smartPlugs.restEnergyUrlHint')}
+                    className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                  />
+                </div>
+                <div className="grid grid-cols-2 gap-3">
                   <div>
                     <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyPath')}</label>
                     <input
@@ -1257,7 +1301,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                       className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                     />
                   </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyMultiplier')}</label>
+                    <input
+                      type="text"
+                      value={restEnergyMultiplier}
+                      onChange={(e) => setRestEnergyMultiplier(e.target.value)}
+                      placeholder="1"
+                      className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                    />
+                  </div>
                 </div>
+
                 <p className="text-xs text-bambu-gray">
                   {t('smartPlugs.restEnergyHint')}
                 </p>

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

@@ -3874,14 +3874,20 @@ export default {
     restStatusUrl: 'Status URL',
     restStatusPath: 'State JSON Path',
     restStatusOnValue: 'ON Value',
+    restPowerUrl: 'Power URL',
     restPowerPath: 'Power JSON Path',
+    restPowerMultiplier: 'Power Multiplikator',
+    restEnergyUrl: 'Energie URL',
     restEnergyPath: 'Energy JSON Path',
+    restEnergyMultiplier: 'Energie Multiplikator',
     restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
     restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
     restBodyHint: 'e.g. ON, {"state": "on"}',
     restStatusHint: 'URL to poll for current state',
     restPathHint: 'e.g. state or data.power.status',
-    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    restPowerUrlHint: 'Eigene URL für Leistungsdaten (nutzt Status URL wenn leer)',
+    restEnergyUrlHint: 'Eigene URL für Energiedaten (nutzt Status URL wenn leer)',
+    restEnergyHint: 'Jeder Wert kann eine eigene URL verwenden oder auf die Status URL zurückgreifen. Multiplikatoren für Einheitenumrechnung verwenden (z.B. 0.001 für Wh zu kWh).',
     testConnection: 'Test Connection',
     connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: 'Keine Schalter in der Schaltleiste',

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

@@ -3880,14 +3880,20 @@ export default {
     restStatusUrl: 'Status URL',
     restStatusPath: 'State JSON Path',
     restStatusOnValue: 'ON Value',
+    restPowerUrl: 'Power URL',
     restPowerPath: 'Power JSON Path',
+    restPowerMultiplier: 'Power Multiplier',
+    restEnergyUrl: 'Energy URL',
     restEnergyPath: 'Energy JSON Path',
+    restEnergyMultiplier: 'Energy Multiplier',
     restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
     restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
     restBodyHint: 'e.g. ON, {"state": "on"}',
     restStatusHint: 'URL to poll for current state',
     restPathHint: 'e.g. state or data.power.status',
-    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    restPowerUrlHint: 'Separate URL for power data (uses Status URL if empty)',
+    restEnergyUrlHint: 'Separate URL for energy data (uses Status URL if empty)',
+    restEnergyHint: 'Each value can use its own URL or fall back to the Status URL. Use multipliers for unit conversion (e.g. 0.001 to convert Wh to kWh).',
     testConnection: 'Test Connection',
     connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: 'No switches in switchbar',

+ 7 - 1
frontend/src/i18n/locales/fr.ts

@@ -3866,14 +3866,20 @@ export default {
     restStatusUrl: 'Status URL',
     restStatusPath: 'State JSON Path',
     restStatusOnValue: 'ON Value',
+    restPowerUrl: 'URL de puissance',
     restPowerPath: 'Power JSON Path',
+    restPowerMultiplier: 'Multiplicateur de puissance',
+    restEnergyUrl: 'URL d\'énergie',
     restEnergyPath: 'Energy JSON Path',
+    restEnergyMultiplier: 'Multiplicateur d\'énergie',
     restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
     restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
     restBodyHint: 'e.g. ON, {"state": "on"}',
     restStatusHint: 'URL to poll for current state',
     restPathHint: 'e.g. state or data.power.status',
-    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    restPowerUrlHint: 'URL séparée pour les données de puissance (utilise l\'URL de statut si vide)',
+    restEnergyUrlHint: 'URL séparée pour les données d\'énergie (utilise l\'URL de statut si vide)',
+    restEnergyHint: 'Chaque valeur peut utiliser sa propre URL ou se rabattre sur l\'URL de statut. Utilisez les multiplicateurs pour la conversion d\'unités (ex : 0.001 pour convertir Wh en kWh).',
     testConnection: 'Test Connection',
     connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: 'Aucun commutateur dans la barre',

+ 7 - 1
frontend/src/i18n/locales/it.ts

@@ -3865,14 +3865,20 @@ export default {
     restStatusUrl: 'Status URL',
     restStatusPath: 'State JSON Path',
     restStatusOnValue: 'ON Value',
+    restPowerUrl: 'URL potenza',
     restPowerPath: 'Power JSON Path',
+    restPowerMultiplier: 'Moltiplicatore potenza',
+    restEnergyUrl: 'URL energia',
     restEnergyPath: 'Energy JSON Path',
+    restEnergyMultiplier: 'Moltiplicatore energia',
     restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
     restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
     restBodyHint: 'e.g. ON, {"state": "on"}',
     restStatusHint: 'URL to poll for current state',
     restPathHint: 'e.g. state or data.power.status',
-    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    restPowerUrlHint: 'URL separato per i dati di potenza (usa l\'URL di stato se vuoto)',
+    restEnergyUrlHint: 'URL separato per i dati di energia (usa l\'URL di stato se vuoto)',
+    restEnergyHint: 'Ogni valore può usare il proprio URL o ricadere sull\'URL di stato. Usa i moltiplicatori per la conversione delle unità (es. 0.001 per convertire Wh in kWh).',
     testConnection: 'Test Connection',
     connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: 'Nessun interruttore nella barra',

+ 7 - 1
frontend/src/i18n/locales/ja.ts

@@ -3878,14 +3878,20 @@ export default {
     restStatusUrl: 'Status URL',
     restStatusPath: 'State JSON Path',
     restStatusOnValue: 'ON Value',
+    restPowerUrl: '電力URL',
     restPowerPath: 'Power JSON Path',
+    restPowerMultiplier: '電力乗数',
+    restEnergyUrl: 'エネルギーURL',
     restEnergyPath: 'Energy JSON Path',
+    restEnergyMultiplier: 'エネルギー乗数',
     restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
     restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
     restBodyHint: 'e.g. ON, {"state": "on"}',
     restStatusHint: 'URL to poll for current state',
     restPathHint: 'e.g. state or data.power.status',
-    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    restPowerUrlHint: '電力データ用の個別URL(空欄の場合はステータスURLを使用)',
+    restEnergyUrlHint: 'エネルギーデータ用の個別URL(空欄の場合はステータスURLを使用)',
+    restEnergyHint: '各値は個別のURLを使用するか、ステータスURLにフォールバックできます。乗数で単位変換が可能です(例:WhからkWhへの変換は0.001)。',
     testConnection: 'Test Connection',
     connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: 'スイッチバーにスイッチがありません',

+ 7 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -3865,14 +3865,20 @@ export default {
     restStatusUrl: 'Status URL',
     restStatusPath: 'State JSON Path',
     restStatusOnValue: 'ON Value',
+    restPowerUrl: 'URL de potência',
     restPowerPath: 'Power JSON Path',
+    restPowerMultiplier: 'Multiplicador de potência',
+    restEnergyUrl: 'URL de energia',
     restEnergyPath: 'Energy JSON Path',
+    restEnergyMultiplier: 'Multiplicador de energia',
     restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
     restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
     restBodyHint: 'e.g. ON, {"state": "on"}',
     restStatusHint: 'URL to poll for current state',
     restPathHint: 'e.g. state or data.power.status',
-    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    restPowerUrlHint: 'URL separada para dados de potência (usa a URL de status se vazio)',
+    restEnergyUrlHint: 'URL separada para dados de energia (usa a URL de status se vazio)',
+    restEnergyHint: 'Cada valor pode usar sua própria URL ou recorrer à URL de status. Use multiplicadores para conversão de unidades (ex: 0.001 para converter Wh em kWh).',
     testConnection: 'Test Connection',
     connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: 'Nenhum interruptor na barra',

+ 7 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -3865,14 +3865,20 @@ export default {
     restStatusUrl: 'Status URL',
     restStatusPath: 'State JSON Path',
     restStatusOnValue: 'ON Value',
+    restPowerUrl: '功率URL',
     restPowerPath: 'Power JSON Path',
+    restPowerMultiplier: '功率乘数',
+    restEnergyUrl: '能耗URL',
     restEnergyPath: 'Energy JSON Path',
+    restEnergyMultiplier: '能耗乘数',
     restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
     restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
     restBodyHint: 'e.g. ON, {"state": "on"}',
     restStatusHint: 'URL to poll for current state',
     restPathHint: 'e.g. state or data.power.status',
-    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    restPowerUrlHint: '功率数据的独立URL(留空则使用状态URL)',
+    restEnergyUrlHint: '能耗数据的独立URL(留空则使用状态URL)',
+    restEnergyHint: '每个值可以使用独立的URL,或回退到状态URL。使用乘数进行单位转换(例如:0.001 将 Wh 转换为 kWh)。',
     testConnection: 'Test Connection',
     connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: '开关栏中没有开关',

Plik diff jest za duży
+ 0 - 0
static/assets/index-DZQd_9Li.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-aAUZWkDJ.js"></script>
+    <script type="module" crossorigin src="/assets/index-DZQd_9Li.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BGA3I7Jb.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików