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

Merged
merged 11 commits into from
Jan 16, 2025
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
172 changes: 172 additions & 0 deletions companion/lib/Surface/FirmwareUpdateCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
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: unknown
}

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

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

readonly #payloadUpdating = new Map<string, Promise<unknown | 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(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).catch((e) => {
this.#logger.error(`Failed to check for firmware updates for surface "${surfaceId}": ${e}`)
})
})
)
})
)

// 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<unknown | 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<unknown | null>()
this.#payloadUpdating.set(url, pendingPromise)

// Fetch new data
fetch(url)
.then((res) => res.json() as Promise<unknown>)
.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: unknown | 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?: unknown) => Promise<void>
}
export interface DrawButtonItem {
x: number
Expand Down
77 changes: 77 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,30 @@ function getConfigFields(streamDeck: StreamDeck): CompanionSurfaceConfigField[]
return fields
}

interface FirmwareVersionInfo {
productIds: number[]
versions: Record<string, string>
}

/**
* The latest firmware versions for the SDS at the time this was last updated
*/
const LATEST_FIRMWARE_VERSIONS: FirmwareVersionInfo[] = [
// Tool is not ready, so there are no versions to compare
// {
// // Studio
// productIds: [0x00aa],
// versions: {
// AP2: '1.05.009',
// ENCODER_AP2: '1.01.012',
// ENCODER_LD: '1.01.006',
// },
// },
]
const STREAMDECK_MODULES_SUPPORTING_UPDATES: ReadonlySet<DeviceModelId> = new Set([DeviceModelId.STUDIO])
const STREAMDECK_UPDATE_DOWNLOAD_URL = 'http://api.bitfocus.io/v1/product/elgato-updater/download'
const STREAMDECK_UPDATE_VERSIONS_URL = 'http://api.bitfocus.io/v1/product/elgato-updater/versions'

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

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

if (STREAMDECK_MODULES_SUPPORTING_UPDATES.has(this.#streamDeck.MODEL)) {
this.info.firmwareUpdateVersionsUrl = STREAMDECK_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 +365,45 @@ export class SurfaceUSBElgatoStreamDeck extends EventEmitter<SurfacePanelEvents>
return self
}

async checkForFirmwareUpdates(latestVersions0?: unknown): Promise<void> {
let latestVersions: FirmwareVersionInfo[] | undefined = latestVersions0 as FirmwareVersionInfo[]
// If no versions are provided, use the latest known versions for the SDS
if (!latestVersions) latestVersions = LATEST_FIRMWARE_VERSIONS

// This should probably be cached, but it is cheap to check
const deviceInfo = await this.#streamDeck.getHidDeviceInfo()
const latestVersionsForDevice = latestVersions.find((info) => info.productIds.includes(deviceInfo.productId))

// If no versions are provided, we can't know that there are updates
if (!latestVersionsForDevice) {
this.info.hasFirmwareUpdates = undefined
return
}

let hasUpdate = false

const currentVersions = await this.#streamDeck.getAllFirmwareVersions()

for (const [key, targetVersion] of Object.entries(latestVersionsForDevice.versions)) {
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: STREAMDECK_UPDATE_DOWNLOAD_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 +449,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])}`)
}
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.2.2",
"@types/node": "^22.10.5",
"@types/ps-tree": "^1.1.6",
"chokidar": "^3.6.0",
"concurrently": "^9.1.2",
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
Loading
Loading