HMSErrorModal.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. // HMS Error Modal. Descriptions come from the backend (HMSError.description),
  2. // which resolves them from the catalogue generated out of Bambu Studio (#2728).
  3. import { useEffect } from 'react';
  4. import { useTranslation } from 'react-i18next';
  5. import { useMutation, useQueryClient } from '@tanstack/react-query';
  6. import { X, AlertTriangle, AlertCircle, Info, ExternalLink, Loader2, Trash2 } from 'lucide-react';
  7. import type { HMSError, Permission } from '../api/client';
  8. import { api } from '../api/client';
  9. import { useToast } from '../contexts/ToastContext';
  10. interface HMSErrorModalProps {
  11. printerName: string;
  12. errors: HMSError[];
  13. onClose: () => void;
  14. printerId: number;
  15. hasPermission: (permission: Permission) => boolean;
  16. // Runout guidance for a PAUSED print (#2587). When set, AMS-runout errors are
  17. // re-described to name the physical slot the firmware now expects, instead of
  18. // the generic "insert into the same slot" text (wrong under AMS Filament Backup).
  19. // Slot labels are pre-formatted (e.g. "AMS-A · Slot 3"); null when the slot
  20. // could not be resolved → an honest "check the printer" message is shown.
  21. runoutGuidance?: {
  22. expectedSlotLabel: string | null;
  23. ranOutSlotLabel: string | null;
  24. } | null;
  25. }
  26. // AMS per-slot filament-runout short codes (module 0x07). These pause the print
  27. // waiting for a specific slot — the ones #2587 re-describes. Printer-side /
  28. // external runout (0300_8004) has no AMS slot and is deliberately excluded.
  29. const AMS_RUNOUT_SHORT_CODES = new Set([
  30. '0700_8011', '0701_8011', '0702_8011', '0703_8011', '0704_8011',
  31. '0705_8011', '0706_8011', '0707_8011', '07FF_8011',
  32. ]);
  33. // "MQTT command verification failed" — the firmware's authorization check
  34. // refusing a control command. Matched on its full 16-char code: this error's
  35. // meaning lives in attr's low half (0500) and code's high half (0001), both of
  36. // which getShortCode() discards. Bambu's own text for it says to update Studio
  37. // or Handy, which is no help from Bambuddy, so the modal says what it means here.
  38. export const HMS_MQTT_VERIFY_FAILED = '0500050000010007';
  39. type SeverityInfo = { labelKey: string; color: string; bgColor: string; Icon: typeof AlertTriangle };
  40. // Bambu's alert levels: 1 error (the task is stopped), 2 warning (the task is
  41. // paused), 3 notification (no impact). 0 is Bambu's "invalid" level and
  42. // anything else is not defined, so both read as unknown rather than as
  43. // information (#2728).
  44. function getSeverityInfo(severity: number): SeverityInfo {
  45. switch (severity) {
  46. case 1:
  47. return { labelKey: 'hmsErrors.severityError', color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-100 dark:bg-red-500/20', Icon: AlertTriangle };
  48. case 2:
  49. return { labelKey: 'hmsErrors.severityWarning', color: 'text-orange-700 dark:text-orange-400', bgColor: 'bg-orange-100 dark:bg-orange-500/20', Icon: AlertTriangle };
  50. case 3:
  51. return { labelKey: 'hmsErrors.severityNotice', color: 'text-blue-700 dark:text-blue-400', bgColor: 'bg-blue-100 dark:bg-blue-500/20', Icon: Info };
  52. default:
  53. return { labelKey: 'hmsErrors.severityUnknown', color: 'text-bambu-gray', bgColor: 'bg-bambu-dark-tertiary', Icon: AlertCircle };
  54. }
  55. }
  56. // A fault that stops or pauses the task — what turns the printer card's HMS
  57. // indicator red instead of amber.
  58. export function isSevereHMSError(error: HMSError): boolean {
  59. return error.severity === 1 || error.severity === 2;
  60. }
  61. function getShortCode(attr: number, code: number): string {
  62. // Convert attr and code to short format: XXXX_YYYY
  63. // attr contains the module info, code contains the error number
  64. const module = ((attr >> 16) & 0xFFFF) || ((attr >> 8) & 0xFF) << 8 | (attr & 0xFF);
  65. const codeNum = code & 0xFFFF;
  66. return `${module.toString(16).padStart(4, '0').toUpperCase()}_${codeNum.toString(16).padStart(4, '0').toUpperCase()}`;
  67. }
  68. // The code as the printer screen and Bambu's wiki write it: four groups for an
  69. // hms[] fault ("0500-0300-0002-000E"), two for a print_error ("0300-8004").
  70. function getDisplayCode(error: HMSError): string {
  71. if (error.full_code && error.full_code.length === 16) return error.full_code.match(/.{4}/g)!.join('-');
  72. const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;
  73. return getShortCode(error.attr, codeNum).replace('_', '-');
  74. }
  75. // Same rule as the backend's _hms_fault_counts, which decides notifications and
  76. // the MQTT relay.
  77. function counts(error: HMSError): boolean {
  78. if (!(error.severity >= 1)) return false;
  79. if ((error.actions?.length ?? 0) > 0) return true;
  80. const isHmsNotice = error.full_code?.length === 16 && error.severity === 3;
  81. return !!error.description && !isHmsNotice;
  82. }
  83. // The faults that count: the printer card's badge and pip, the camera wall,
  84. // the bulk toolbar's problem state. A fault counts if it offers firmware
  85. // actions — an uncatalogued but actionable fault such as H2C 0500_809C must
  86. // surface so its buttons can render — or if Bambu publishes text for it
  87. // (HMSError.description, resolved by the backend from the catalogue generated
  88. // out of Bambu Studio). Two kinds don't count: codes Bambu lists without text or
  89. // not at all, such as the post-cancel 0C00_001B echo, which would otherwise hold
  90. // a "1 problem" badge on a printer that has none (see
  91. // PrintersPageBucketing.test.ts); and hms[] notices (level 3) without actions,
  92. // such as "the top cover is open", which a printer can hold through a whole
  93. // print. A print_error prompt at the same level (0xCxxx) still counts, as it
  94. // always has (#2728).
  95. export function filterKnownHMSErrors(errors: HMSError[]): HMSError[] {
  96. return errors.filter(counts);
  97. }
  98. // The faults the printer reported that don't count. The modal still lists them,
  99. // collapsed and with whatever text Bambu has for them, so a fault the printer
  100. // is holding is never invisible (#2728).
  101. export function filterUncountedHMSErrors(errors: HMSError[]): HMSError[] {
  102. return errors.filter((error) => !counts(error));
  103. }
  104. function getHMSHomeUrl(): string {
  105. return `https://wiki.bambulab.com/en/hms/home`;
  106. }
  107. export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPermission, runoutGuidance }: HMSErrorModalProps) {
  108. const { t } = useTranslation();
  109. const { showToast } = useToast();
  110. const queryClient = useQueryClient();
  111. const clearMutation = useMutation({
  112. mutationFn: () => api.clearHMSErrors(printerId),
  113. onSuccess: () => {
  114. showToast(t('hmsErrors.clearSuccess'), 'success');
  115. onClose();
  116. },
  117. onError: () => {
  118. showToast(t('hmsErrors.clearFailed'), 'error');
  119. },
  120. });
  121. // The faults that count, exactly as the badge counts them, then the ones the
  122. // printer reported that don't, listed collapsed underneath.
  123. const knownErrors = filterKnownHMSErrors(errors);
  124. const uncountedErrors = filterUncountedHMSErrors(errors);
  125. // Close on Escape key
  126. useEffect(() => {
  127. const handleKeyDown = (e: KeyboardEvent) => {
  128. if (e.key === 'Escape') onClose();
  129. };
  130. window.addEventListener('keydown', handleKeyDown);
  131. return () => window.removeEventListener('keydown', handleKeyDown);
  132. }, [onClose]);
  133. // printerStatusMutation with optimistic update
  134. const activateActionMutation = useMutation({
  135. mutationFn: (data: {
  136. action: string,
  137. print_error: string,
  138. job_id: string | null,
  139. }) => api.executeHMSAction(printerId, {
  140. action: data.action,
  141. print_error: data.print_error,
  142. job_id: data.job_id,
  143. }),
  144. onSuccess: () => {
  145. // Scope the invalidation to THIS printer. The prefix form
  146. // `['printerStatus']` would refresh every printer card on the page,
  147. // which is wasteful when only one printer's state actually changed.
  148. queryClient.invalidateQueries({ queryKey: ['printerStatus', printerId] });
  149. showToast(t('hmsErrors.actionSuccess', 'Action sent to printer'), 'success');
  150. onClose();
  151. },
  152. onError: (error: Error) => {
  153. showToast(
  154. `${t('hmsErrors.actionFailed', 'Failed to send action')}: ${error.message}`,
  155. 'error',
  156. );
  157. },
  158. });
  159. return (
  160. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
  161. <div className="bg-bambu-dark-secondary rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">
  162. {/* Header */}
  163. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  164. <div className="flex items-center gap-2">
  165. <AlertTriangle className="w-5 h-5 text-orange-600 dark:text-orange-400" />
  166. <h2 className="text-lg font-semibold text-white">{t('hmsErrors.title', { name: printerName })}</h2>
  167. </div>
  168. <button
  169. onClick={onClose}
  170. className="p-1 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
  171. >
  172. <X className="w-5 h-5 text-bambu-gray" />
  173. </button>
  174. </div>
  175. {/* Content */}
  176. <div className="flex-1 overflow-y-auto p-4">
  177. {knownErrors.length === 0 && uncountedErrors.length === 0 ? (
  178. <div className="text-center py-8 text-bambu-gray">
  179. <AlertCircle className="w-12 h-12 mx-auto mb-3 opacity-30" />
  180. <p>{t('hmsErrors.noErrors')}</p>
  181. </div>
  182. ) : (
  183. <div className="space-y-3">
  184. {knownErrors.map((error, index) => {
  185. const { labelKey, color, bgColor, Icon } = getSeverityInfo(error.severity);
  186. const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;
  187. const shortCode = getShortCode(error.attr, codeNum);
  188. // Runout guidance (#2587): for an AMS per-slot runout on a paused
  189. // print, name the slot the firmware now expects rather than the
  190. // misleading generic "insert into the same slot" text.
  191. // An actionable fault can count without text; it still gets a line.
  192. let description = error.description || t('hmsErrors.unknownCode');
  193. // The text and remedy are Bambuddy's, not Bambu's — their text says
  194. // "update Studio or Handy", which is no help to someone printing
  195. // from Bambuddy. Same override shape as the runout guidance below.
  196. const isVerifyFailed = error.full_code === HMS_MQTT_VERIFY_FAILED;
  197. if (isVerifyFailed) description = t('hmsErrors.mqttVerifyFailedDescription');
  198. const remedy = isVerifyFailed ? t('hmsErrors.mqttVerifyFailedRemedy') : null;
  199. if (runoutGuidance && AMS_RUNOUT_SHORT_CODES.has(shortCode)) {
  200. if (runoutGuidance.expectedSlotLabel && runoutGuidance.ranOutSlotLabel) {
  201. description = t('hmsErrors.runoutExpectedSlot', {
  202. expected: runoutGuidance.expectedSlotLabel,
  203. ranOut: runoutGuidance.ranOutSlotLabel,
  204. });
  205. } else if (runoutGuidance.expectedSlotLabel) {
  206. description = t('hmsErrors.runoutExpectedSlotOnly', {
  207. expected: runoutGuidance.expectedSlotLabel,
  208. });
  209. } else {
  210. description = t('hmsErrors.runoutSlotUnknown');
  211. }
  212. }
  213. const hmsHomeUrl = getHMSHomeUrl();
  214. const displayCode = getDisplayCode(error);
  215. return (
  216. <div
  217. key={`${error.code}-${index}`}
  218. className={`p-4 rounded-lg ${bgColor} border border-white/10`}
  219. >
  220. <div className="flex items-start gap-3">
  221. <Icon className={`w-5 h-5 ${color} flex-shrink-0 mt-0.5`} />
  222. <div className="flex-1 min-w-0">
  223. <div className="flex items-center gap-2 mb-1">
  224. <span className={`font-mono text-sm ${color}`}>[{displayCode}]</span>
  225. <span className={`text-xs px-2 py-0.5 rounded-full ${bgColor} ${color}`}>
  226. {t(labelKey)}
  227. </span>
  228. </div>
  229. <p className="text-sm text-bambu-gray mb-2">{description}</p>
  230. {remedy && <p className="text-sm text-bambu-gray mb-2">{remedy}</p>}
  231. {error.actions && error.actions.length > 0 && (
  232. <div className="flex flex-wrap gap-2 my-2">
  233. {error.actions.map((action) => {
  234. const pendingVars = activateActionMutation.variables;
  235. const isThisPending =
  236. activateActionMutation.isPending
  237. && pendingVars?.action === action
  238. && pendingVars?.print_error === (error.full_code || shortCode.replace('_', ''));
  239. return (
  240. <button
  241. key={action}
  242. onClick={() => {
  243. // full_code is the firmware-matching key (16
  244. // chars for hms[]-array faults, 8 chars for
  245. // print_error). Fall back to the 8-char
  246. // shortCode for older backends that haven't
  247. // populated it. See #1830.
  248. activateActionMutation.mutate({
  249. action,
  250. print_error: error.full_code || shortCode.replace('_', ''),
  251. job_id: error.job_id ?? null,
  252. });
  253. }}
  254. // Static hover/active classes — Tailwind's JIT
  255. // can't resolve `hover:${var}` template
  256. // literals, so the previous severity-tinted
  257. // hover never reached the compiled CSS and
  258. // the action buttons read as inert badges.
  259. // White-on-tint reads as a clear affordance
  260. // against any severity-coloured container.
  261. disabled={!hasPermission('printers:control') || activateActionMutation.isPending}
  262. className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-white/10 hover:bg-white/20 active:bg-white/30 text-white border border-white/20 hover:border-white/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
  263. >
  264. {isThisPending && <Loader2 className="w-4 h-4 animate-spin" />}
  265. {t(`hmsErrors.actions.${action}`, action)}
  266. </button>
  267. );
  268. })}
  269. </div>
  270. )}
  271. <a
  272. href={hmsHomeUrl}
  273. target="_blank"
  274. rel="noopener noreferrer"
  275. className="inline-flex items-center gap-1 text-xs text-bambu-green hover:underline"
  276. >
  277. <ExternalLink className="w-3 h-3" />
  278. {t('hmsErrors.viewOnWiki')}
  279. </a>
  280. </div>
  281. </div>
  282. </div>
  283. );
  284. })}
  285. {uncountedErrors.length > 0 && (
  286. <details className="rounded-lg border border-white/10 p-3" data-testid="hms-uncounted">
  287. <summary className="cursor-pointer text-sm text-bambu-gray">
  288. {t('hmsErrors.uncountedSummary', { n: uncountedErrors.length })}
  289. </summary>
  290. <p className="mt-2 text-xs text-bambu-gray">{t('hmsErrors.uncountedHint')}</p>
  291. <ul className="mt-2 space-y-2">
  292. {uncountedErrors.map((error, index) => (
  293. <li key={`${error.full_code || error.code}-${index}`} className="text-xs">
  294. <div className="flex items-center gap-2">
  295. <span className="font-mono text-bambu-gray">[{getDisplayCode(error)}]</span>
  296. <span className="text-bambu-gray">{t(getSeverityInfo(error.severity).labelKey)}</span>
  297. </div>
  298. {error.description && <p className="mt-0.5 text-bambu-gray">{error.description}</p>}
  299. </li>
  300. ))}
  301. </ul>
  302. </details>
  303. )}
  304. </div>
  305. )}
  306. </div>
  307. {/* Footer */}
  308. <div className="p-4 border-t border-bambu-dark-tertiary flex items-center justify-between gap-3">
  309. <p className="text-xs text-bambu-gray">
  310. {t('hmsErrors.clearInstructions')}
  311. </p>
  312. {errors.length > 0 && (
  313. <button
  314. onClick={() => clearMutation.mutate()}
  315. disabled={!hasPermission('printers:control') || clearMutation.isPending}
  316. className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
  317. >
  318. {clearMutation.isPending ? (
  319. <Loader2 className="w-4 h-4 animate-spin" />
  320. ) : (
  321. <Trash2 className="w-4 h-4" />
  322. )}
  323. {t('hmsErrors.clearErrors')}
  324. </button>
  325. )}
  326. </div>
  327. </div>
  328. </div>
  329. );
  330. }