Skip to content
Open
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
35 changes: 34 additions & 1 deletion src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import type { EmberPlusConfig } from './config.js'
import type { EmberPlusInstance } from './index.js'
import { doMatrixAction, doTake, doClear, setSelectedSource, setSelectedTarget } from './actions/matrix.js'
import { learnSetValueActionOptions, setValue, subscribeParameterAction } from './actions/parameter.js'
import { invokeFunctionAction } from './actions/function.js'
import { EmberPlusState } from './state.js'
import { filterPathChoices } from './util.js'
import { filterPathChoices, filterFunctionPathChoices } from './util.js'

export interface setValueActionOptions extends CompanionOptionValues {
path: string
Expand Down Expand Up @@ -47,6 +48,7 @@ export enum ActionId {
Clear = 'clear',
SetSelectedSource = 'setSelectedSource',
SetSelectedTarget = 'setSelectedTarget',
InvokeFunction = 'invokeFunction',
}

const pathDropDown = {
Expand Down Expand Up @@ -560,6 +562,37 @@ export function GetActionsList(
],
callback: setSelectedTarget(self, state),
},
[ActionId.InvokeFunction]: {
name: 'Invoke Function',
options: [
{
...pathDropDown,
choices: filterFunctionPathChoices(state),
default: filterFunctionPathChoices(state).find(() => true)?.id ?? 'No functions configured!',
},
pathString,
usePathVar,
{
type: 'textinput',
label: 'Arguments',
id: 'args',
useVariables: { local: true },
multiline: true,
default: '',
tooltip:
'Enter arguments comma-separated, line-separated, or as a JSON array (e.g. [123, "text", true]). Dynamic variables are supported.',
},
{
type: 'checkbox',
label: 'Parse escape characters',
id: 'parseEscapeChars',
default: true,
tooltip: 'Parse escape characters such as \\r \\n \\t in text arguments',
},
],
callback: invokeFunctionAction(self, emberClient, state, queue),
subscribe: subscribeParameterAction(self),
},
}

return actions
Expand Down
96 changes: 96 additions & 0 deletions src/actions/function.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from 'vitest'
import { invokeFunctionAction } from './function.js'
import { Model as EmberModel } from 'emberplus-connection'
import { ElementType } from 'emberplus-connection/dist/model/index.js'

describe('invokeFunctionAction callback', () => {
it('invokes emberClient.invoke with correctly parsed arguments', async () => {
const mockSelf: any = { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } }
const mockResponse = Promise.resolve({ success: true, result: [] })
const mockEmberClient: any = {
getElementByPath: vi.fn(),
invoke: vi.fn().mockResolvedValue({ response: mockResponse }),
}
const funcNode: any = {
contents: {
type: ElementType.Function,
identifier: 'MyFunc',
args: [{ type: EmberModel.ParameterType.Integer, name: 'id' }],
},
}
const mockState: any = {
emberElement: new Map([['1.2.3', funcNode]]),
updateFunctionMap: vi.fn(),
}
const mockQueue: any = {
add: vi.fn().mockImplementation((fn: any) => fn()),
}

const callback = invokeFunctionAction(mockSelf, mockEmberClient, mockState, mockQueue)

const action: any = {
options: {
path: '1.2.3',
usePathVar: false,
args: '42',
parseEscapeChars: true,
},
}

const context: any = {
parseVariablesInString: vi.fn().mockImplementation(async (str) => str),
}

await callback(action, context)

expect(mockEmberClient.invoke).toHaveBeenCalledWith(
funcNode,
{ type: EmberModel.ParameterType.Integer, value: 42 },
)
expect(mockSelf.logger.info).toHaveBeenCalledWith(
'Function "1.2.3" invoked successfully',
[],
)
})

it('logs warning when path is empty', async () => {
const mockSelf: any = { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } }
const mockEmberClient: any = { invoke: vi.fn() }
const mockState: any = { emberElement: new Map() }
const mockQueue: any = { add: vi.fn().mockImplementation((fn: any) => fn()) }

const callback = invokeFunctionAction(mockSelf, mockEmberClient, mockState, mockQueue)
const action: any = { options: { path: '', usePathVar: false, args: '' } }
const context: any = { parseVariablesInString: vi.fn().mockImplementation(async (str) => str) }

await callback(action, context)

expect(mockSelf.logger.warn).toHaveBeenCalledWith('Invoke Function: Path is empty')
expect(mockEmberClient.invoke).not.toHaveBeenCalled()
})

it('logs error when target node is not a Function', async () => {
const mockSelf: any = { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } }
const mockEmberClient: any = { invoke: vi.fn() }
const paramNode: any = {
contents: {
type: ElementType.Parameter,
identifier: 'Param1',
},
}
const mockState: any = {
emberElement: new Map([['1.2.3', paramNode]]),
updateFunctionMap: vi.fn(),
}
const mockQueue: any = { add: vi.fn().mockImplementation((fn: any) => fn()) }

const callback = invokeFunctionAction(mockSelf, mockEmberClient, mockState, mockQueue)
const action: any = { options: { path: '1.2.3', usePathVar: false, args: '' } }
const context: any = { parseVariablesInString: vi.fn().mockImplementation(async (str) => str) }

await callback(action, context)

expect(mockSelf.logger.error).toHaveBeenCalledWith('Invoke Function: Node at path "1.2.3" is not a valid Ember+ Function')
expect(mockEmberClient.invoke).not.toHaveBeenCalled()
})
})
59 changes: 59 additions & 0 deletions src/actions/function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { CompanionActionEvent, CompanionActionContext } from '@companion-module/base'
import { EmberClient, Model as EmberModel } from 'emberplus-connection'
import { ElementType } from 'emberplus-connection/dist/model/index.js'
import type PQueue from 'p-queue'
import type { EmberPlusInstance } from '../index.js'
import { EmberPlusState } from '../state.js'
import { parseEscapeCharacters, parseFunctionArguments, resolveEventPath } from '../util.js'

export const invokeFunctionAction =
(self: EmberPlusInstance, emberClient: EmberClient, state: EmberPlusState, queue: PQueue) =>
async (action: CompanionActionEvent, context: CompanionActionContext): Promise<void> => {
const path = resolveEventPath(action)
if (!path) {
self.logger.warn('Invoke Function: Path is empty')
return
}

await queue.add(async () => {
try {
let node = state.emberElement.get(path)
if (!node) {
node = await emberClient.getElementByPath(path)
if (node) {
state.emberElement.set(path, node)
if (node.contents.type === ElementType.Function) {
state.updateFunctionMap(path, node)
}
}
}

if (!node || node.contents.type !== ElementType.Function) {
self.logger.error(`Invoke Function: Node at path "${path}" is not a valid Ember+ Function`)
return
}

const rawArgsInput = action.options['args']?.toString() ?? ''
const parsedArgsString = await context.parseVariablesInString(rawArgsInput)
const finalArgsString = action.options['parseEscapeChars']
? parseEscapeCharacters(parsedArgsString)
: parsedArgsString

const emberFunc = node.contents as EmberModel.EmberFunction
const typedArgs = parseFunctionArguments(finalArgsString, emberFunc.args)

self.logger.debug(`Invoking Ember+ Function at "${path}" with arguments:`, typedArgs)

const request = await emberClient.invoke(node as any, ...typedArgs)
const result = await request.response

if (result?.success) {
self.logger.info(`Function "${path}" invoked successfully`, result.result ?? '')
} else {
self.logger.warn(`Function "${path}" invocation failed or returned false`, result ?? '')
}
} catch (e) {
self.logger.error(`Failed to invoke function at "${path}":`, e instanceof Error ? e.message : String(e))
}
})
}
23 changes: 20 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
hasConnectionChanged,
recordParameterAction,
parseParameterValue,
discoverFunctionsFromTree,
} from './util.js'
import { GetVariablesList } from './variables.js'
import PQueue from 'p-queue'
Expand Down Expand Up @@ -252,6 +253,7 @@ export class EmberPlusInstance extends InstanceBase<EmberPlusConfig> {
try {
const request = await this.emberClient.getDirectory(this.emberClient.tree)
await request.response
discoverFunctionsFromTree(this.emberClient.tree, this.state)
this.statusManager.updateStatus(InstanceStatus.Ok)
this.finalizeSetup().catch((e) => {
this.logger.error('Error during finalize setup', e)
Expand Down Expand Up @@ -345,8 +347,12 @@ export class EmberPlusInstance extends InstanceBase<EmberPlusConfig> {
})
if (initial_node) {
this.logger.debug('Registered for path', path)
this.state.updateParameterMap(path, initial_node)
await this.handleChangedValue(path, initial_node)
if (initial_node.contents.type === ElementType.Function) {
this.state.updateFunctionMap(path, initial_node)
} else if (initial_node.contents.type === ElementType.Parameter) {
this.state.updateParameterMap(path, initial_node)
await this.handleChangedValue(path, initial_node)
}
}
} catch {
this.logger.error('Failed to subscribe to path', path)
Expand Down Expand Up @@ -377,7 +383,18 @@ export class EmberPlusInstance extends InstanceBase<EmberPlusConfig> {
this.handleChangedValue(path, updatedNode).catch((e) => this.logger.error('Error handling parameter', e))
})

if (!node || node.contents.type !== ElementType.Parameter) {
if (!node) {
return node
}

if (node.contents.type === ElementType.Function) {
this.logger.debug('Registered function for path', path)
this.state.updateFunctionMap(path, node)
this.debouncedUpdateActionFeedbackDefs()
return node
}

if (node.contents.type !== ElementType.Parameter) {
return node
}

Expand Down
69 changes: 69 additions & 0 deletions src/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ vi.mock('emberplus-connection/dist/model', () => ({
ElementType: {
Parameter: 'parameter',
Node: 'node',
Function: 'function',
},
}))

Expand All @@ -24,6 +25,17 @@ function makeNode(overrides: Record<string, any> = {}) {
} as any
}

function makeFunctionNode(overrides: Record<string, any> = {}) {
return {
contents: {
type: ElementType.Function,
identifier: 'muteFunction',
description: 'Mute Channel',
...overrides,
},
} as any
}

// ---------------------------------------------------------------------------
// constructor / initial state
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -310,4 +322,61 @@ describe('clear', () => {
it('can be called on an already-empty state without throwing', () => {
new EmberPlusState().clear()
})

it('clears functions map as well', () => {
const state = new EmberPlusState()
state.updateFunctionMap('0.1.2', makeFunctionNode())
expect(state.functions.size).toBe(1)
state.clear()
expect(state.functions.size).toBe(0)
})
})

// ---------------------------------------------------------------------------
// clearCache
// ---------------------------------------------------------------------------

describe('clearCache', () => {
it('clears emberElement map', () => {
const state = new EmberPlusState()
state.updateFunctionMap('0.1.2', makeFunctionNode())
expect(state.emberElement.size).toBe(1)
state.clearCache()
expect(state.emberElement.size).toBe(0)
})
})

// ---------------------------------------------------------------------------
// updateFunctionMap / getFunction / hasFunction
// ---------------------------------------------------------------------------

describe('updateFunctionMap / getFunction / hasFunction', () => {
it('stores function node in functions and emberElement maps', () => {
const state = new EmberPlusState()
const funcNode = makeFunctionNode({ identifier: 'fn1' })
state.updateFunctionMap('1.2.3', funcNode)

expect(state.hasFunction('1.2.3')).toBe(true)
expect(state.getFunction('1.2.3')?.identifier).toBe('fn1')
expect(state.emberElement.get('1.2.3')).toBe(funcNode)
})

it('ignores non-function elements', () => {
const state = new EmberPlusState()
const paramNode = makeNode()
state.updateFunctionMap('1.2.3', paramNode)

expect(state.hasFunction('1.2.3')).toBe(false)
expect(state.getFunction('1.2.3')).toBeUndefined()
})

it('merges data for existing function path', () => {
const state = new EmberPlusState()
state.updateFunctionMap('1.2.3', makeFunctionNode({ identifier: 'fn1', description: 'Old' }))
state.updateFunctionMap('1.2.3', makeFunctionNode({ identifier: 'fn1', description: 'New description' }))

const stored = state.getFunction('1.2.3')
expect(stored?.identifier).toBe('fn1')
expect(stored?.description).toBe('New description')
})
})
Loading