diff --git a/companion/lib/Surface/Controller.ts b/companion/lib/Surface/Controller.ts index c9957ea33b..e16cb5adfe 100644 --- a/companion/lib/Surface/Controller.ts +++ b/companion/lib/Surface/Controller.ts @@ -19,7 +19,7 @@ import findProcess from 'find-process' import HID from 'node-hid' import jsonPatch from 'fast-json-patch' -import { cloneDeep } from 'lodash-es' +import { cloneDeep, isEqual } from 'lodash-es' import { nanoid } from 'nanoid' import pDebounce from 'p-debounce' import { getStreamDeckDeviceInfo } from '@elgato-stream-deck/node' @@ -341,6 +341,24 @@ export class SurfaceController extends EventEmitter { // Update the group to have the new surface this.#attachSurfaceToGroup(handler) + + // Perform an update check in the background + setTimeout(() => { + const firmwareUpdatesBefore = panel.info.hasFirmwareUpdates + panel + .checkForFirmwareUpdates?.() + .then(() => { + if (isEqual(firmwareUpdatesBefore, panel.info.hasFirmwareUpdates)) return + + this.#logger.info(`Firmware updates change for surface "${handler.surfaceId}"`) + + // Inform ui of the updates + this.updateDevicesList() + }) + .catch((e) => { + this.#logger.warn(`Failed to check for firmware updates for surface "${handler.surfaceId}": ${e}`) + }) + }, 0) } /** @@ -682,6 +700,7 @@ export class SurfaceController extends EventEmitter { isConnected: !!surfaceHandler, displayName: getSurfaceName(config, id), location: null, + hasFirmwareUpdates: null, size: config.gridSize || null, offset: { columns: config?.config?.xOffset ?? 0, rows: config?.config?.yOffset ?? 0 }, @@ -693,6 +712,7 @@ export class SurfaceController extends EventEmitter { surfaceInfo.location = location || null surfaceInfo.configFields = surfaceHandler.panel.info.configFields || [] + surfaceInfo.hasFirmwareUpdates = surfaceHandler.panel.info.hasFirmwareUpdates || null } return surfaceInfo diff --git a/companion/lib/Surface/Types.ts b/companion/lib/Surface/Types.ts index e90c974a12..7685c264ed 100644 --- a/companion/lib/Surface/Types.ts +++ b/companion/lib/Surface/Types.ts @@ -1,4 +1,8 @@ -import type { CompanionSurfaceConfigField, GridSize } from '@companion-app/shared/Model/Surfaces.js' +import type { + CompanionSurfaceConfigField, + GridSize, + SurfaceFirmwareUpdateInfo, +} from '@companion-app/shared/Model/Surfaces.js' import type { ImageResult } from '../Graphics/ImageResult.js' import type { EventEmitter } from 'events' import type { CompanionVariableValue, CompanionVariableValues } from '@companion-module/base' @@ -29,7 +33,9 @@ export interface SurfacePanelInfo { type: string configFields: CompanionSurfaceConfigField[] location?: string + hasFirmwareUpdates?: SurfaceFirmwareUpdateInfo } + export interface SurfacePanel extends EventEmitter { readonly info: SurfacePanelInfo readonly gridSize: GridSize @@ -41,6 +47,7 @@ export interface SurfacePanel extends EventEmitter { getDefaultConfig?: () => any onVariablesChanged?: (allChangedVariables: Set) => void quit(): void + checkForFirmwareUpdates?: (latestVersions?: Record) => Promise } export interface DrawButtonItem { x: number diff --git a/companion/lib/Surface/USB/ElgatoStreamDeck.ts b/companion/lib/Surface/USB/ElgatoStreamDeck.ts index 0e3a68d4b5..cb6f991aef 100644 --- a/companion/lib/Surface/USB/ElgatoStreamDeck.ts +++ b/companion/lib/Surface/USB/ElgatoStreamDeck.ts @@ -33,6 +33,7 @@ import type { CompanionSurfaceConfigField, GridSize } from '@companion-app/share import type { SurfacePanel, SurfacePanelEvents, SurfacePanelInfo } from '../Types.js' import type { LcdPosition, StreamDeckLcdSegmentControlDefinition, StreamDeckTcp } from '@elgato-stream-deck/tcp' import type { ImageResult } from '../../Graphics/ImageResult.js' +import { SemVer } from 'semver' const setTimeoutPromise = util.promisify(setTimeout) @@ -63,6 +64,18 @@ function getConfigFields(streamDeck: StreamDeck): CompanionSurfaceConfigField[] return fields } +/** + * The latest firmware versions for the SDS at the time this was last updated + */ +const LATEST_SDS_FIRMWARE_VERSIONS: Record = { + AP2: '1.05.009', + ENCODER_AP2_1: '1.01.012', + ENCODER_AP2_2: '1.01.012', + ENCODER_LD_1: '1.01.006', + ENCODER_LD_2: '1.01.006', +} +const SDS_UPDATE_TOOL_URL = 'https://bitfocus.io/?elgato-sds-firmware-updater' + export class SurfaceUSBElgatoStreamDeck extends EventEmitter implements SurfacePanel { readonly #logger: Logger @@ -336,6 +349,41 @@ export class SurfaceUSBElgatoStreamDeck extends EventEmitter return self } + async checkForFirmwareUpdates(latestVersions?: Record): Promise { + // If no versions are provided, use the latest known versions for the SDS + if (!latestVersions && this.#streamDeck.MODEL === DeviceModelId.STUDIO) + latestVersions = LATEST_SDS_FIRMWARE_VERSIONS + + // If no versions are provided, we can't know that there are updates + if (!latestVersions) { + this.info.hasFirmwareUpdates = undefined + return + } + + let hasUpdate = false + + const currentVersions = await this.#streamDeck.getAllFirmwareVersions() + + for (const [key, targetVersion] of Object.entries(latestVersions)) { + const currentVersion = parseVersion(currentVersions[key]) + const latestVersion = parseVersion(targetVersion) + + if (currentVersion && latestVersion && latestVersion.compare(currentVersion) > 0) { + this.#logger.info(`Firmware update available for ${key}: ${currentVersion} -> ${latestVersion}`) + hasUpdate = true + break + } + } + + if (hasUpdate) { + this.info.hasFirmwareUpdates = { + updaterDownloadUrl: SDS_UPDATE_TOOL_URL, + } + } else { + this.info.hasFirmwareUpdates = undefined + } + } + /** * Process the information from the GUI and what is saved in database * @returns false when nothing happens @@ -381,3 +429,12 @@ export class SurfaceUSBElgatoStreamDeck extends EventEmitter this.#writeQueue.queue(`${x}_${y}`, x, y, render) } } + +function parseVersion(rawVersion: string): SemVer | null { + // These versions are not semver, but can hopefully be safely cooerced into it + + const parts = rawVersion.split('.') + if (parts.length !== 3) return null + + return new SemVer(`${parseInt(parts[0])}.${parseInt(parts[1])}.${parseInt(parts[2])}`) +} diff --git a/shared-lib/lib/Model/Surfaces.ts b/shared-lib/lib/Model/Surfaces.ts index c9498db35f..30b458b6f8 100644 --- a/shared-lib/lib/Model/Surfaces.ts +++ b/shared-lib/lib/Model/Surfaces.ts @@ -16,6 +16,10 @@ export interface RowsAndColumns { columns: number } +export interface SurfaceFirmwareUpdateInfo { + updaterDownloadUrl: string +} + export interface ClientSurfaceItem { id: string type: string @@ -26,6 +30,8 @@ export interface ClientSurfaceItem { displayName: string location: string | null + hasFirmwareUpdates: SurfaceFirmwareUpdateInfo | null + size: RowsAndColumns | null offset: RowsAndColumns | null } diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 5fb1f6a7e6..6ae201a523 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -46,6 +46,7 @@ import { ImportExport } from './ImportExport/index.js' import { RootAppStoreContext } from './Stores/RootAppStore.js' import { observer } from 'mobx-react-lite' import { ConnectionVariables } from './Variables/index.js' +import { SurfacesTabNotifyIcon } from './Surfaces/TabNotifyIcon.js' const useTouchBackend = window.localStorage.getItem('test_touch_backend') === '1' const showCloudTab = window.localStorage.getItem('show_companion_cloud') === '1' @@ -456,7 +457,7 @@ const AppContent = observer(function AppContent({ buttonGridHotPress }: AppConte - Surfaces + Surfaces diff --git a/webui/src/Stores/SurfacesStore.tsx b/webui/src/Stores/SurfacesStore.tsx index c2da1bba5c..27c2015d9f 100644 --- a/webui/src/Stores/SurfacesStore.tsx +++ b/webui/src/Stores/SurfacesStore.tsx @@ -119,4 +119,18 @@ export class SurfacesStore { surfaces: overflowingSurfaces, } } + + public countFirmwareUpdates(): number { + let count = 0 + + for (const group of this.store.values()) { + for (const surface of group.surfaces) { + if (surface.hasFirmwareUpdates) { + count++ + } + } + } + + return count + } } diff --git a/webui/src/Surfaces/KnownSurfacesTable.tsx b/webui/src/Surfaces/KnownSurfacesTable.tsx index 1d70929ac6..f57b378132 100644 --- a/webui/src/Surfaces/KnownSurfacesTable.tsx +++ b/webui/src/Surfaces/KnownSurfacesTable.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useContext, useRef } from 'react' import { CButton, CButtonGroup } from '@coreui/react' import { socketEmitPromise } from '../util.js' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faCog, faFolderOpen, faSearch, faTrash } from '@fortawesome/free-solid-svg-icons' +import { faCircleUp, faCog, faFolderOpen, faSearch, faTrash } from '@fortawesome/free-solid-svg-icons' import { TextInputField } from '../Components/TextInputField.js' import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal.js' import { SurfaceEditModal, SurfaceEditModalRef } from './EditModal.js' @@ -11,6 +11,7 @@ import { ClientDevicesListItem, ClientSurfaceItem } from '@companion-app/shared/ import { RootAppStoreContext } from '../Stores/RootAppStore.js' import { observer } from 'mobx-react-lite' import { NonIdealState } from '../Components/NonIdealState.js' +import { WindowLinkOpen } from '../Helpers/Window.js' export const KnownSurfacesTable = observer(function SurfacesPage() { const { surfaces, socket } = useContext(RootAppStoreContext) @@ -236,7 +237,17 @@ function SurfaceRow({ - {surface.type} + + {surface.type} + {!!surface.hasFirmwareUpdates && ( + <> + {' '} + + + + + )} + {surface.isConnected ? surface.location || 'Local' : 'Offline'} {surface.isConnected ? ( diff --git a/webui/src/Surfaces/TabNotifyIcon.tsx b/webui/src/Surfaces/TabNotifyIcon.tsx new file mode 100644 index 0000000000..353c832a2b --- /dev/null +++ b/webui/src/Surfaces/TabNotifyIcon.tsx @@ -0,0 +1,16 @@ +import { observer } from 'mobx-react-lite' +import React, { useContext } from 'react' +import { RootAppStoreContext } from '../Stores/RootAppStore.js' + +export const SurfacesTabNotifyIcon = observer(function SurfacesTabNotifyIcon(): JSX.Element | null { + const { surfaces } = useContext(RootAppStoreContext) + + const updateCount = surfaces.countFirmwareUpdates() + if (updateCount === 0) return null + + return ( + + {updateCount} + + ) +}) diff --git a/webui/src/scss/_common.scss b/webui/src/scss/_common.scss index ff83f41b74..c7019bcfd3 100644 --- a/webui/src/scss/_common.scss +++ b/webui/src/scss/_common.scss @@ -310,3 +310,19 @@ table .td-reorder { display: grid; grid-template-columns: minmax(0, 1fr); } + +.notification-count { + background-color: $primary; + color: #fff; + font-weight: bold; + + border-radius: 50%; + // padding: 0.1rem; + font-size: 0.7rem; + vertical-align: top; + text-align: center; + + margin-right: -0.5rem; + min-width: calc(var(--cui-body-line-height) * 1em); + display: inline-block; +}