Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: sds firmware update #3142

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions companion/lib/Surface/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { createOrSanitizeSurfaceHandlerConfig } from './Config.js'
import { EventEmitter } from 'events'
import LogController from '../Log/Controller.js'
import type { DataDatabase } from '../Data/Database.js'
import { SurfaceFirmwareUpdateCheck } from './FirmwareUpdateCheck.js'

// Force it to load the hidraw driver just in case
HID.setDriverType('hidraw')
Expand Down Expand Up @@ -133,6 +134,8 @@ export class SurfaceController extends EventEmitter<SurfaceControllerEvents> {

readonly #outboundController: SurfaceOutboundController

readonly #firmwareUpdates: SurfaceFirmwareUpdateCheck

constructor(db: DataDatabase, handlerDependencies: SurfaceHandlerDependencies, io: UIHandler) {
super()

Expand All @@ -141,6 +144,7 @@ export class SurfaceController extends EventEmitter<SurfaceControllerEvents> {
this.#io = io

this.#outboundController = new SurfaceOutboundController(this, db, io)
this.#firmwareUpdates = new SurfaceFirmwareUpdateCheck(this.#surfaceHandlers, () => this.updateDevicesList())

this.#surfacesAllLocked = !!this.#handlerDependencies.userconfig.getKey('link_lockouts')

Expand Down Expand Up @@ -341,6 +345,9 @@ export class SurfaceController extends EventEmitter<SurfaceControllerEvents> {

// Update the group to have the new surface
this.#attachSurfaceToGroup(handler)

// Perform an update check in the background
this.#firmwareUpdates.triggerCheckSurfaceForUpdates(handler)
}

/**
Expand Down Expand Up @@ -682,6 +689,7 @@ export class SurfaceController extends EventEmitter<SurfaceControllerEvents> {
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 },
Expand All @@ -693,6 +701,7 @@ export class SurfaceController extends EventEmitter<SurfaceControllerEvents> {

surfaceInfo.location = location || null
surfaceInfo.configFields = surfaceHandler.panel.info.configFields || []
surfaceInfo.hasFirmwareUpdates = surfaceHandler.panel.info.hasFirmwareUpdates || null
}

return surfaceInfo
Expand Down
170 changes: 170 additions & 0 deletions companion/lib/Surface/FirmwareUpdateCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { isEqual } from 'lodash-es'
import type { SurfaceHandler } from './Handler.js'
import LogController from '../Log/Controller.js'

const FIRMWARE_UPDATE_POLL_INTERVAL = 1000 * 60 * 60 * 24 // 24 hours
const FIRMWARE_PAYLOAD_CACHE_TTL = 1000 * 60 * 60 * 4 // 4 hours
const FIRMWARE_PAYLOAD_CACHE_MAX_TTL = 1000 * 60 * 60 * 24 // 24 hours

interface PayloadCacheEntry {
timestamp: number
payload: Record<string, string>
}

export class SurfaceFirmwareUpdateCheck {
readonly #logger = LogController.createLogger('Surface/FirmwareUpdateCheck')

readonly #payloadCache = new Map<string, PayloadCacheEntry>()

readonly #payloadUpdating = new Map<string, Promise<Record<string, string> | null>>()

/**
* All the opened and active surfaces
*/
readonly #surfaceHandlers: Map<string, SurfaceHandler | null>

readonly #updateDevicesList: () => void

constructor(surfaceHandlers: Map<string, SurfaceHandler | null>, updateDevicesList: () => void) {
this.#surfaceHandlers = surfaceHandlers
this.#updateDevicesList = updateDevicesList

setInterval(() => this.#checkAllSurfacesForUpdates(), FIRMWARE_UPDATE_POLL_INTERVAL)
setTimeout(() => this.#checkAllSurfacesForUpdates(), 5000)
}

#checkAllSurfacesForUpdates() {
// Compile a list of all urls to check, and the surfaces that use them
const allUpdateUrls = new Map<string, string[]>()
for (const [surfaceId, handler] of this.#surfaceHandlers) {
if (!handler) continue
const updateUrl = handler.panel.info.firmwareUpdateVersionsUrl
if (!updateUrl) continue

const currentList = allUpdateUrls.get(updateUrl)
if (currentList) {
currentList.push(handler.surfaceId)
} else {
allUpdateUrls.set(updateUrl, [surfaceId])
}
}

// No updates to check
if (allUpdateUrls.size === 0) return

this.#logger.debug(`Checking for firmware updates from ${allUpdateUrls.size} urls`)

Promise.resolve()
.then(async () => {
await Promise.allSettled(
Array.from(allUpdateUrls).map(async ([url, surfaceIds]) => {
// Scrape the api for an updated payload
const versionsInfo = await this.#fetchPayloadForUrl(url, true)

// Perform the update for each surface
await Promise.allSettled(
surfaceIds.map((surfaceId) => {
const handler = this.#surfaceHandlers.get(surfaceId)
if (!handler) return

return this.#performForSurface(handler, versionsInfo)
})
)
})
)

// Inform the ui, even though there may be no changes
this.#updateDevicesList()
})
.catch((e) => {
this.#logger.warn(`Failed to check for firmware updates: ${e}`)
})
}

/**
* Fetch the payload for a specific url, either from cache or from the server
* @param url The url to fetch the payload from
* @param skipCache Whether to skip the cache and always fetch a new payload
* @returns The payload, or null if it could not be fetched
*/
async #fetchPayloadForUrl(url: string, skipCache?: boolean): Promise<Record<string, string> | null> {
let cacheEntry = this.#payloadCache.get(url)

// Check if the cache is too old to be usable
if (cacheEntry && cacheEntry.timestamp < Date.now() - FIRMWARE_PAYLOAD_CACHE_MAX_TTL) {
cacheEntry = undefined
this.#payloadCache.delete(url)
}

// Check if cache is new enough to return directly
if (!skipCache && cacheEntry && cacheEntry.timestamp < Date.now() - FIRMWARE_PAYLOAD_CACHE_TTL) {
return cacheEntry.payload
}

// If one is in flight, return that
const currentInFlight = this.#payloadUpdating.get(url)
if (currentInFlight) return currentInFlight

// @ts-expect-error
const { promise: pendingPromise, resolve } = Promise.withResolvers<Record<string, string> | null>()
this.#payloadUpdating.set(url, pendingPromise)

// Fetch new data
fetch(url)
.then((res) => res.json() as Promise<Record<string, string>>)
.catch((e) => {
this.#logger.warn(`Failed to fetch firmware update payload from "${url}": ${e}`)
return null
})
.then((newPayload) => {
// Update cache with the new value
if (newPayload) {
this.#payloadCache.set(url, { timestamp: Date.now(), payload: newPayload })
}

// No longer in flight
this.#payloadUpdating.delete(url)

// Return the new value
resolve(newPayload || cacheEntry?.payload || null)
})

return pendingPromise
}

/**
* Trigger a check for updates for a specific surface
* @param surface Surface to check for updates
*/
triggerCheckSurfaceForUpdates(surface: SurfaceHandler): void {
setTimeout(() => {
Promise.resolve()
.then(async () => {
// fetch latest versions info
const versionsInfo = surface.panel.info.firmwareUpdateVersionsUrl
? await this.#fetchPayloadForUrl(surface.panel.info.firmwareUpdateVersionsUrl)
: null

const changed = await this.#performForSurface(surface, versionsInfo)

// Inform ui of the updates
if (changed) this.#updateDevicesList()
})

.catch((e) => {
this.#logger.warn(`Failed to check for firmware updates for surface "${surface.surfaceId}": ${e}`)
})
}, 0)
}

async #performForSurface(surface: SurfaceHandler, versionsInfo: Record<string, string> | null): Promise<boolean> {
// Check if panel has updates
const firmwareUpdatesBefore = surface.panel.info.hasFirmwareUpdates
await surface.panel.checkForFirmwareUpdates?.(versionsInfo ?? undefined)
if (isEqual(firmwareUpdatesBefore, surface.panel.info.hasFirmwareUpdates)) return false

this.#logger.info(`Firmware updates change for surface "${surface.surfaceId}"`)

return true
}
}
10 changes: 9 additions & 1 deletion companion/lib/Surface/Types.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -29,7 +33,10 @@ export interface SurfacePanelInfo {
type: string
configFields: CompanionSurfaceConfigField[]
location?: string
firmwareUpdateVersionsUrl?: string
hasFirmwareUpdates?: SurfaceFirmwareUpdateInfo
}

export interface SurfacePanel extends EventEmitter<SurfacePanelEvents> {
readonly info: SurfacePanelInfo
readonly gridSize: GridSize
Expand All @@ -41,6 +48,7 @@ export interface SurfacePanel extends EventEmitter<SurfacePanelEvents> {
getDefaultConfig?: () => any
onVariablesChanged?: (allChangedVariables: Set<string>) => void
quit(): void
checkForFirmwareUpdates?: (latestVersions?: Record<string, string>) => Promise<void>
}
export interface DrawButtonItem {
x: number
Expand Down
60 changes: 60 additions & 0 deletions companion/lib/Surface/USB/ElgatoStreamDeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -63,6 +64,17 @@ 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<string, string> = {
AP2: '1.05.009',
ENCODER_AP2: '1.01.012',
ENCODER_LD: '1.01.006',
}
const SDS_UPDATE_TOOL_URL = 'https://bitfocus.io/?elgato-sds-firmware-updater'
const SDS_UPDATE_VERSIONS_URL = 'https://builds.julusian.dev/builds/sds-test.json'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make more sense to serve this from GitHub raw like so?:
https://raw.githubusercontent.com/bitfocus/companion/refs/heads/main/nodejs-versions.json

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of these urls are placeholders currently, waiting on the update tool to be available


export class SurfaceUSBElgatoStreamDeck extends EventEmitter<SurfacePanelEvents> implements SurfacePanel {
readonly #logger: Logger

Expand Down Expand Up @@ -106,6 +118,10 @@ export class SurfaceUSBElgatoStreamDeck extends EventEmitter<SurfacePanelEvents>
location: undefined, // set later
}

if (this.#streamDeck.MODEL === DeviceModelId.STUDIO) {
this.info.firmwareUpdateVersionsUrl = SDS_UPDATE_VERSIONS_URL
}

const allRowValues = this.#streamDeck.CONTROLS.map((control) => control.row)
const allColumnValues = this.#streamDeck.CONTROLS.map((button) => button.column)

Expand Down Expand Up @@ -336,6 +352,41 @@ export class SurfaceUSBElgatoStreamDeck extends EventEmitter<SurfacePanelEvents>
return self
}

async checkForFirmwareUpdates(latestVersions?: Record<string, string>): Promise<void> {
// 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
Expand Down Expand Up @@ -381,3 +432,12 @@ export class SurfaceUSBElgatoStreamDeck extends EventEmitter<SurfacePanelEvents>
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])}`)
}
4 changes: 2 additions & 2 deletions companion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@
"@blackmagic-controller/node": "^0.1.0",
"@companion-app/shared": "*",
"@companion-module/base": "^1.11.0",
"@elgato-stream-deck/node": "^7.0.2",
"@elgato-stream-deck/tcp": "^7.0.2",
"@elgato-stream-deck/node": "^7.1.1",
"@elgato-stream-deck/tcp": "^7.1.1",
"@julusian/bonjour-service": "^1.3.0-2",
"@julusian/image-rs": "^1.1.1",
"@julusian/jpeg-turbo": "^2.2.0",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
},
"devDependencies": {
"@inquirer/prompts": "^7.0.1",
"@types/node": "^22.9.0",
"@types/ps-tree": "^1.1.6",
"chokidar": "^3.6.0",
"concurrently": "^9.0.1",
Expand Down
6 changes: 6 additions & 0 deletions shared-lib/lib/Model/Surfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export interface RowsAndColumns {
columns: number
}

export interface SurfaceFirmwareUpdateInfo {
updaterDownloadUrl: string
}

export interface ClientSurfaceItem {
id: string
type: string
Expand All @@ -26,6 +30,8 @@ export interface ClientSurfaceItem {
displayName: string
location: string | null

hasFirmwareUpdates: SurfaceFirmwareUpdateInfo | null

size: RowsAndColumns | null
offset: RowsAndColumns | null
}
Expand Down
3 changes: 2 additions & 1 deletion webui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -456,7 +457,7 @@ const AppContent = observer(function AppContent({ buttonGridHotPress }: AppConte
</CNavItem>
<CNavItem>
<CNavLink to={SURFACES_PAGE_PREFIX} as={NavLink}>
<FontAwesomeIcon icon={faGamepad} /> Surfaces
<FontAwesomeIcon icon={faGamepad} /> Surfaces <SurfacesTabNotifyIcon />
</CNavLink>
</CNavItem>
<CNavItem>
Expand Down
Loading