Просмотр исходного кода

fix(notifications): send Pushover retry/expire for Emergency priority (#2586)

Pushover rejects priority-2 (Emergency) messages unless they carry retry
and expire. _send_pushover never sent them, so setting priority 2 always
failed with Pushover's "retry and expire are required" error. Now at
priority 2 we send retry/expire (default 60s/3600s, clamped to Pushover's
30-10800s range), surfaced as two provider fields shown only when priority
is 2. Added PushoverConfig schema fields, i18n labels across all locales,
and unit tests.
maziggy 1 месяц назад
Родитель
Сommit
1555fad539

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
 
 ### Fixed
+- **Pushover Emergency priority (2) was rejected by the Pushover API (#2586)** — Setting a Pushover provider to priority 2 (Emergency) made every notification fail with Pushover's own error that `retry` and `expire` are required. Pushover *mandates* those two parameters for Emergency alerts — `retry` is how often it re-alerts (minimum 30 s) and `expire` is when it stops (maximum 10800 s / 3 h) — and Bambuddy never sent them, so the message was refused before it left the app. Priority 2 now works: two new optional fields (Emergency Retry / Expire) appear on the Pushover provider **only when priority is set to 2**, default to a sensible 60 s / 3600 s, are clamped to Pushover's legal 30–10800 s range, and are sent only at priority 2 (Pushover ignores them at other priorities). Emergency alerts now keep re-alerting until acknowledged, as intended.
 - **P2S RTSP timeout could leave the fan-out camera stream permanently stalled (#2580, reported and diagnosed by @ronaldheft, fix shape from PR #2581)** — After an RTSP read timeout, the stream cleanup killed the stalled ffmpeg and then waited *unbounded* for it to be reaped. A SIGKILLed ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily long to exit, so the fan-out stream coroutine sat parked in that wait — in the reported case for 12 hours — while every new viewer attached to the stalled broadcaster and got no frames (snapshots and diagnostics kept working, since those open fresh connections). The post-kill wait is now bounded (2 s): on timeout the stream abandons the zombie — the orphan janitor's /proc scan reaps it on its next pass — and proceeds to its normal reconnect, so live view recovers by itself. The same unbounded wait hid in two more places, both bounded too: the camera *Stop* endpoint (which would hang the very request a user makes to recover a stuck stream) and the periodic orphan-cleanup janitor itself (which is the safety net that recovers stalled streams, and so can least afford to block).
 - **Queue edit showed the sliced-for model as the scheduler target, and a cross-model queue row could dispatch G-code to an incompatible printer (#2578, reporter @Jostxxl)** — Two bugs with one root. The "Any \<model\>" assignment button labeled itself from the file's slice metadata while the scheduler actually used the row's `target_model`, so an X1C-sliced item targeting H2D read "Any X1C" above "Scheduler will assign to first available idle H2D printer". Worse, the mismatch could be *created* silently: the sliced-for model loads asynchronously, and clicking "Any Model" before it arrived pre-selected the first model alphabetically — on a mixed X1C/P1S/H2D farm that's H2D — after which the model dropdown hid itself, leaving no way to see or fix the wrong target. Nothing downstream checked compatibility, so the scheduler would happily hand X1C G-code to an H2D. Now: the target model is never silently defaulted (the dropdown stays visible in model mode, pre-selected to the sliced-for model when available, and back-fills once the metadata loads); the button reflects the actual target; a warning shows when the target differs from the sliced-for model. Compatibility is enforced end-to-end with an explicit G-code interchange family table (X1/X1C/X1E/P1P/P1S interchange; everything else exact-match — files without slice metadata are never blocked): incompatible models are disabled in the dropdown, queue create/update reject a mismatch with a clear 400 (so API-created rows can't sneak in), and the scheduler holds back pre-existing mismatched rows with an actionable waiting reason instead of dispatching them — fix the target via edit and the job flows again.
 - **Manual jog could drive an axis past its travel limit into a collision (#2579, reporter @R3play210)** — Jog the bed up from Bambuddy and, instead of stopping at the travel limit, it keeps going until the nozzle hits the plate; X/Y overrun too, on every model. Instrumenting the exact bytes sent to an H2D showed Bambuddy issuing a clean, correct move at the limit — `G91` / `G1 Z-1.00 F600` / `G90`, no endstop manipulation — that the printer executed straight past the stop, while the machine's **own touchscreen refuses the identical motion**. **This is a Bambu firmware bug: the firmware does not enforce its soft endstops on G-code received over MQTT** (the path every remote tool, Bambuddy included, must use), and it reports no axis position, so Bambuddy cannot know where the bed is to stop it either. It is not fixable from our side. Two things change here: (1) the jog no longer wraps moves in `M211 S0`/`S1` — the old code disabled the firmware's soft endstops *globally* around every jog, which also broke the **touchscreen's** limits until the printer was power-cycled; it now sends a bare move and never touches `M211`, so the touchscreen stays protected. (2) The jog panel now shows a prominent warning that travel limits are **not** enforced during manual moves because of this firmware bug, so nobody trusts the control to stop at the limit. Client-side travel-limit enforcement (dead-reckoning from a home) is tracked separately as the only real mitigation. If your printer currently overruns even from its touchscreen, power-cycle it once to restore the endstops an older Bambuddy build disabled.

+ 4 - 0
backend/app/schemas/notification.py

@@ -233,6 +233,10 @@ class PushoverConfig(BaseModel):
     user_key: str = Field(..., description="Your Pushover user key")
     app_token: str = Field(..., description="Your Pushover application token")
     priority: int = Field(default=0, ge=-2, le=2, description="Message priority (-2 to 2)")
+    # Emergency priority (2) only: how often to re-alert and when to stop.
+    # Pushover requires retry >= 30s and expire <= 10800s (3h).
+    retry: int = Field(default=60, ge=30, le=10800, description="Emergency re-alert interval in seconds (priority 2)")
+    expire: int = Field(default=3600, ge=30, le=10800, description="Emergency alert expiry in seconds (priority 2)")
 
 
 class TelegramConfig(BaseModel):

+ 20 - 1
backend/app/services/notification_service.py

@@ -334,7 +334,10 @@ class NotificationService:
         """
         user_key = config.get("user_key", "").strip()
         app_token = config.get("app_token", "").strip()
-        priority = config.get("priority", 0)
+        try:
+            priority = int(config.get("priority", 0))
+        except (TypeError, ValueError):
+            priority = 0
 
         if not user_key or not app_token:
             return False, "User key and app token are required"
@@ -348,6 +351,22 @@ class NotificationService:
             "priority": priority,
         }
 
+        # Emergency priority (2) keeps re-alerting until acknowledged, so
+        # Pushover *requires* retry (how often, >= 30s) and expire (when to
+        # give up, <= 10800s). Without them the API rejects the message. Only
+        # send them at priority 2 — Pushover ignores them at other priorities.
+        if priority == 2:
+            try:
+                retry = int(config.get("retry", 60))
+            except (TypeError, ValueError):
+                retry = 60
+            try:
+                expire = int(config.get("expire", 3600))
+            except (TypeError, ValueError):
+                expire = 3600
+            data["retry"] = max(30, min(retry, 10800))
+            data["expire"] = max(30, min(expire, 10800))
+
         client = await self._get_client()
 
         if image_data:

+ 83 - 0
backend/tests/unit/test_pushover_priority.py

@@ -0,0 +1,83 @@
+"""Tests for Pushover emergency-priority (2) retry/expire handling (#2586).
+
+Pushover rejects a priority-2 (Emergency) message unless it also carries
+``retry`` and ``expire``. These tests pin that we send those params at
+priority 2 (clamped to Pushover's legal 30..10800 range) and omit them at
+every other priority.
+"""
+
+import httpx
+import pytest
+
+from backend.app.services.notification_service import NotificationService
+
+
+class _CaptureClient:
+    """Minimal stand-in for httpx.AsyncClient that records the posted data."""
+
+    def __init__(self):
+        self.is_closed = False
+        self.last_data: dict | None = None
+
+    async def post(self, url, data=None, files=None):
+        self.last_data = data
+        return httpx.Response(200, json={"status": 1})
+
+
+@pytest.fixture
+def service_with_capture():
+    service = NotificationService()
+    client = _CaptureClient()
+    service._http_client = client  # bypass real HTTP
+    return service, client
+
+
+BASE_CONFIG = {"user_key": "u" * 30, "app_token": "a" * 30}
+
+
+@pytest.mark.asyncio
+async def test_priority_2_includes_retry_and_expire(service_with_capture):
+    service, client = service_with_capture
+    ok, _ = await service._send_pushover({**BASE_CONFIG, "priority": 2, "retry": 90, "expire": 7200}, "T", "M")
+    assert ok
+    assert client.last_data["priority"] == 2
+    assert client.last_data["retry"] == 90
+    assert client.last_data["expire"] == 7200
+
+
+@pytest.mark.asyncio
+async def test_priority_2_uses_defaults_when_unset(service_with_capture):
+    service, client = service_with_capture
+    ok, _ = await service._send_pushover({**BASE_CONFIG, "priority": 2}, "T", "M")
+    assert ok
+    assert client.last_data["retry"] == 60
+    assert client.last_data["expire"] == 3600
+
+
+@pytest.mark.asyncio
+async def test_priority_2_clamps_to_pushover_limits(service_with_capture):
+    service, client = service_with_capture
+    ok, _ = await service._send_pushover({**BASE_CONFIG, "priority": 2, "retry": 5, "expire": 999999}, "T", "M")
+    assert ok
+    assert client.last_data["retry"] == 30  # min 30
+    assert client.last_data["expire"] == 10800  # max 10800
+
+
+@pytest.mark.asyncio
+async def test_priority_2_tolerates_string_values(service_with_capture):
+    service, client = service_with_capture
+    ok, _ = await service._send_pushover({**BASE_CONFIG, "priority": "2", "retry": "120", "expire": "1800"}, "T", "M")
+    assert ok
+    assert client.last_data["priority"] == 2
+    assert client.last_data["retry"] == 120
+    assert client.last_data["expire"] == 1800
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("priority", [-2, -1, 0, 1])
+async def test_non_emergency_priority_omits_retry_and_expire(service_with_capture, priority):
+    service, client = service_with_capture
+    ok, _ = await service._send_pushover({**BASE_CONFIG, "priority": priority, "retry": 90, "expire": 7200}, "T", "M")
+    assert ok
+    assert "retry" not in client.last_data
+    assert "expire" not in client.last_data

+ 18 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -205,6 +205,24 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
           { key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'text', required: true },
           { key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'text', required: true },
           { key: 'priority', label: 'Priority', placeholder: '0 (normal)', type: 'number', required: false },
+          // Emergency priority (2) requires retry/expire — Pushover rejects the
+          // message otherwise. Only shown when priority is set to 2.
+          {
+            key: 'retry',
+            label: t('notifications.pushoverRetry'),
+            placeholder: '60',
+            type: 'number',
+            required: false,
+            showIf: (cfg: Record<string, string>) => cfg.priority === '2',
+          },
+          {
+            key: 'expire',
+            label: t('notifications.pushoverExpire'),
+            placeholder: '3600',
+            type: 'number',
+            required: false,
+            showIf: (cfg: Record<string, string>) => cfg.priority === '2',
+          },
         ];
       case 'telegram':
         return [

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -5546,6 +5546,8 @@ export default {
     userKey: 'Benutzerschlüssel',
     appToken: 'App-Token',
     priority: 'Priorität',
+    pushoverRetry: 'Notfall-Wiederholung (s)',
+    pushoverExpire: 'Notfall-Ablauf (s)',
     botToken: 'Bot-Token',
     chatId: 'Chat-ID',
     smtpServer: 'SMTP-Server',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -5590,6 +5590,8 @@ export default {
     userKey: 'User Key',
     appToken: 'App Token',
     priority: 'Priority',
+    pushoverRetry: 'Emergency Retry (s)',
+    pushoverExpire: 'Emergency Expire (s)',
     botToken: 'Bot Token',
     chatId: 'Chat ID',
     smtpServer: 'SMTP Server',

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -5555,6 +5555,8 @@ export default {
     userKey: 'Clave de usuario',
     appToken: 'Token de la aplicación',
     priority: 'Prioridad',
+    pushoverRetry: 'Reintento de emergencia (s)',
+    pushoverExpire: 'Expiración de emergencia (s)',
     botToken: 'Token del bot',
     chatId: 'ID del chat',
     smtpServer: 'Servidor SMTP',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -5536,6 +5536,8 @@ export default {
     userKey: 'Clé utilisateur',
     appToken: 'Jeton d\'application',
     priority: 'Priorité',
+    pushoverRetry: 'Réessai urgence (s)',
+    pushoverExpire: 'Expiration urgence (s)',
     botToken: 'Jeton du bot',
     chatId: 'ID du chat',
     smtpServer: 'Serveur SMTP',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -5535,6 +5535,8 @@ export default {
     userKey: 'Chiave utente',
     appToken: 'Token applicazione',
     priority: 'Priorità',
+    pushoverRetry: 'Ripetizione emergenza (s)',
+    pushoverExpire: 'Scadenza emergenza (s)',
     botToken: 'Token del bot',
     chatId: 'ID chat',
     smtpServer: 'Server SMTP',

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

@@ -5547,6 +5547,8 @@ export default {
     userKey: 'ユーザーキー',
     appToken: 'アプリトークン',
     priority: '優先度',
+    pushoverRetry: '緊急再通知 (秒)',
+    pushoverExpire: '緊急有効期限 (秒)',
     botToken: 'ボットトークン',
     chatId: 'チャットID',
     smtpServer: 'SMTPサーバー',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -5259,6 +5259,8 @@ export default {
     userKey: '사용자 키',
     appToken: '앱 토큰',
     priority: '우선순위',
+    pushoverRetry: '긴급 재알림 (초)',
+    pushoverExpire: '긴급 만료 (초)',
     botToken: '봇 토큰',
     chatId: '채팅 ID',
     smtpServer: 'SMTP 서버',

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5535,6 +5535,8 @@ export default {
     userKey: 'Chave do Usuário',
     appToken: 'Token do Aplicativo',
     priority: 'Prioridade',
+    pushoverRetry: 'Repetição de emergência (s)',
+    pushoverExpire: 'Expiração de emergência (s)',
     botToken: 'Token do Bot',
     chatId: 'ID do Chat',
     smtpServer: 'Servidor SMTP',

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -5495,6 +5495,8 @@ export default {
     userKey: 'Kullanıcı Anahtarı',
     appToken: 'Uygulama Belirteci',
     priority: 'Öncelik',
+    pushoverRetry: 'Acil yeniden deneme (sn)',
+    pushoverExpire: 'Acil sona erme (sn)',
     botToken: 'Bot Belirteci',
     chatId: 'Sohbet ID',
     smtpServer: 'SMTP Sunucusu',

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5535,6 +5535,8 @@ export default {
     userKey: '用户密钥',
     appToken: '应用令牌',
     priority: '优先级',
+    pushoverRetry: '紧急重试 (秒)',
+    pushoverExpire: '紧急过期 (秒)',
     botToken: '机器人令牌',
     chatId: '聊天 ID',
     smtpServer: 'SMTP 服务器',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5535,6 +5535,8 @@ export default {
     userKey: '使用者金鑰',
     appToken: '應用程式權杖',
     priority: '優先順序',
+    pushoverRetry: '緊急重試 (秒)',
+    pushoverExpire: '緊急逾時 (秒)',
     botToken: '機器人權杖',
     chatId: '聊天 ID',
     smtpServer: 'SMTP 伺服器',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-MvmZEMye.js


+ 1 - 1
static/index.html

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

+ 4 - 4
update_website_wiki.sh

@@ -10,7 +10,7 @@ git add .
 git commit -m "Updated Wiki"
 git push
 
-cd /opt/claude/projects/bambuddy-sponsors-portal
-git add .
-git commit -m "Updated portal"
-git push
+#cd /opt/claude/projects/bambuddy-sponsors-portal
+#git add .
+#git commit -m "Updated portal"
+#git push

Некоторые файлы не были показаны из-за большого количества измененных файлов