diff --git a/src/config/backends/config-txt.ts b/src/config/backends/config-txt.ts index 4eb46f9a..aecb0a06 100644 --- a/src/config/backends/config-txt.ts +++ b/src/config/backends/config-txt.ts @@ -70,14 +70,15 @@ function isBaseParam(dtparam: string): boolean { * - {BALENA|RESIN}_HOST_CONFIG_gpio = value | "value" | "value1","value2" */ export class ConfigTxt extends ConfigBackend { - private static bootConfigVarPrefix = `${constants.hostConfigVarPrefix}CONFIG_`; - private static bootConfigPath = hostUtils.pathOnBoot('config.txt'); - - public static bootConfigVarRegex = new RegExp( - '(?:' + _.escapeRegExp(ConfigTxt.bootConfigVarPrefix) + ')(.+)', + private static PREFIX = `${constants.hostConfigVarPrefix}CONFIG_`; + private static PATH = hostUtils.pathOnBoot('config.txt'); + private static REGEX = new RegExp( + '(?:' + _.escapeRegExp(ConfigTxt.PREFIX) + ')(.+)', ); - - private static forbiddenConfigKeys = [ + // These keys are not config.txt keys and are managed by the power-fan backend. + private static UNSUPPORTED_KEYS = ['power_mode', 'fan_profile']; + // These keys are config.txt keys, but are not mutable by the Supervisor. + private static FORBIDDEN_KEYS = [ 'disable_commandline_tags', 'cmdline', 'kernel', @@ -89,7 +90,7 @@ export class ConfigTxt extends ConfigBackend { 'device_tree_address', 'init_emmc_clock', 'avoid_safe_mode', - ]; + ].concat(ConfigTxt.UNSUPPORTED_KEYS); public async matches(deviceType: string): Promise { return ( @@ -109,11 +110,8 @@ export class ConfigTxt extends ConfigBackend { public async getBootConfig(): Promise { let configContents = ''; - if (await exists(ConfigTxt.bootConfigPath)) { - configContents = await hostUtils.readFromBoot( - ConfigTxt.bootConfigPath, - 'utf-8', - ); + if (await exists(ConfigTxt.PATH)) { + configContents = await hostUtils.readFromBoot(ConfigTxt.PATH, 'utf-8'); } else { return {}; } @@ -227,19 +225,29 @@ export class ConfigTxt extends ConfigBackend { } const confStr = `${confStatements.join('\n')}\n`; - await hostUtils.writeToBoot(ConfigTxt.bootConfigPath, confStr); + await hostUtils.writeToBoot(ConfigTxt.PATH, confStr); + } + + public static stripPrefix(name: string): string { + if (!name.startsWith(ConfigTxt.PREFIX)) { + return name; + } + return name.substring(ConfigTxt.PREFIX.length); } public isSupportedConfig(configName: string): boolean { - return !ConfigTxt.forbiddenConfigKeys.includes(configName); + return !ConfigTxt.FORBIDDEN_KEYS.includes(configName); } public isBootConfigVar(envVar: string): boolean { - return envVar.startsWith(ConfigTxt.bootConfigVarPrefix); + return ( + envVar.startsWith(ConfigTxt.PREFIX) && + !ConfigTxt.UNSUPPORTED_KEYS.includes(ConfigTxt.stripPrefix(envVar)) + ); } public processConfigVarName(envVar: string): string { - return envVar.replace(ConfigTxt.bootConfigVarRegex, '$1'); + return envVar.replace(ConfigTxt.REGEX, '$1'); } public processConfigVarValue(key: string, value: string): string | string[] { @@ -254,7 +262,7 @@ export class ConfigTxt extends ConfigBackend { } public createConfigVarName(configName: string): string { - return ConfigTxt.bootConfigVarPrefix + configName; + return ConfigTxt.PREFIX + configName; } // Ensure that the balena-fin overlay is defined in the target configuration diff --git a/src/config/backends/index.ts b/src/config/backends/index.ts index cab6fbcf..31955e0f 100644 --- a/src/config/backends/index.ts +++ b/src/config/backends/index.ts @@ -4,6 +4,8 @@ import { ConfigTxt } from './config-txt'; import { ConfigFs } from './config-fs'; import { Odmdata } from './odmdata'; import { SplashImage } from './splash-image'; +import { PowerFanConfig } from './power-fan'; +import { configJsonBackend } from '..'; export const allBackends = [ new Extlinux(), @@ -12,6 +14,7 @@ export const allBackends = [ new ConfigFs(), new Odmdata(), new SplashImage(), + new PowerFanConfig(configJsonBackend), ]; export function matchesAnyBootConfig(envVar: string): boolean { diff --git a/src/config/backends/power-fan.ts b/src/config/backends/power-fan.ts new file mode 100644 index 00000000..0e99d431 --- /dev/null +++ b/src/config/backends/power-fan.ts @@ -0,0 +1,159 @@ +import { isRight } from 'fp-ts/lib/Either'; +import Reporter from 'io-ts-reporters'; +import * as t from 'io-ts'; + +import { ConfigBackend } from './backend'; +import type { ConfigOptions } from './backend'; +import { schemaTypes } from '../schema-type'; +import log from '../../lib/supervisor-console'; +import * as constants from '../../lib/constants'; + +type ConfigJsonBackend = { + get: (key: 'os') => Promise; + set: (opts: { os: Record }) => Promise; +}; + +/** + * A backend to handle Jetson power and fan control + * + * Supports: + * - {BALENA|RESIN}_HOST_CONFIG_power_mode = "low" | "mid" | "high" | "default" |"$MODE_ID" + * - {BALENA|RESIN}_HOST_CONFIG_fan_profile = "quiet" | "cool" | "default" |"$MODE_ID" + */ +export class PowerFanConfig extends ConfigBackend { + private static readonly CONFIGS = new Set(['power_mode', 'fan_profile']); + private static readonly PREFIX = `${constants.hostConfigVarPrefix}CONFIG_`; + private static readonly SCHEMA = t.exact( + t.partial({ + power: t.exact( + t.partial({ + mode: t.string, + }), + ), + fan: t.exact( + t.partial({ + profile: t.string, + }), + ), + }), + ); + + private readonly configJson: ConfigJsonBackend; + public constructor(configJson: ConfigJsonBackend) { + super(); + this.configJson = configJson; + } + + public static stripPrefix(name: string): string { + if (!name.startsWith(PowerFanConfig.PREFIX)) { + return name; + } + return name.substring(PowerFanConfig.PREFIX.length); + } + + public async matches(deviceType: string): Promise { + // We only support Jetpack 6 devices for now, which includes all Orin devices + // except for jetson-orin-nx-xv3 which is still on Jetpack 5 as of OS v5.1.36 + return new Set([ + 'jetson-agx-orin-devkit', + 'jetson-agx-orin-devkit-64gb', + 'jetson-orin-nano-devkit-nvme', + 'jetson-orin-nano-seeed-j3010', + 'jetson-orin-nx-seeed-j4012', + 'jetson-orin-nx-xavier-nx-devkit', + ]).has(deviceType); + } + + public async getBootConfig(): Promise { + // Get raw config.json contents + let rawConf: unknown; + try { + rawConf = await this.configJson.get('os'); + } catch (e: unknown) { + log.error( + `Failed to read config.json while getting power / fan configs: ${(e as Error).message ?? e}`, + ); + return {}; + } + + // Decode to power fan schema from object type, filtering out unrelated values + const powerFanConfig = PowerFanConfig.SCHEMA.decode(rawConf); + + if (isRight(powerFanConfig)) { + const conf = powerFanConfig.right; + return { + ...(conf.power?.mode != null && { + power_mode: conf.power.mode, + }), + ...(conf.fan?.profile != null && { + fan_profile: conf.fan.profile, + }), + }; + } else { + return {}; + } + } + + public async setBootConfig(opts: ConfigOptions): Promise { + // Read raw configs for "os" key from config.json + let rawConf; + try { + rawConf = await this.configJson.get('os'); + } catch (err: unknown) { + log.error(`${(err as Error).message ?? err}`); + return; + } + + // Decode to "os" object type while leaving in unrelated values + const maybeCurrentConf = schemaTypes.os.type.decode(rawConf); + if (!isRight(maybeCurrentConf)) { + log.error( + 'Failed to decode current os config:', + Reporter.report(maybeCurrentConf), + ); + return; + } + // Current config could be undefined if there's no os key in config.json, so default to empty object + const conf = maybeCurrentConf.right ?? {}; + + // Update or delete power mode + if ('power_mode' in opts) { + conf.power = { + mode: opts.power_mode, + }; + } else { + delete conf?.power; + } + + // Update or delete fan profile + if ('fan_profile' in opts) { + conf.fan = { + profile: opts.fan_profile, + }; + } else { + delete conf?.fan; + } + + await this.configJson.set({ os: conf }); + } + + public isSupportedConfig = (name: string): boolean => { + return PowerFanConfig.CONFIGS.has(PowerFanConfig.stripPrefix(name)); + }; + + public isBootConfigVar(envVar: string): boolean { + return PowerFanConfig.CONFIGS.has(PowerFanConfig.stripPrefix(envVar)); + } + + public processConfigVarName(envVar: string): string { + return PowerFanConfig.stripPrefix(envVar).toLowerCase(); + } + + public processConfigVarValue(_key: string, value: string): string { + return value; + } + + public createConfigVarName(name: string): string | null { + return `${PowerFanConfig.PREFIX}${name}`; + } +} diff --git a/src/config/configJson.ts b/src/config/configJson.ts index 335faf90..719b8757 100644 --- a/src/config/configJson.ts +++ b/src/config/configJson.ts @@ -57,9 +57,8 @@ export default class ConfigJsonConfigBackend { public async get(key: Schema.SchemaKey): Promise { await this.init(); - return Bluebird.using( - this.readLockConfigJson(), - async () => this.cache[key], + return Bluebird.using(this.readLockConfigJson(), async () => + structuredClone(this.cache[key]), ); } diff --git a/test/integration/config/power-fan.spec.ts b/test/integration/config/power-fan.spec.ts new file mode 100644 index 00000000..18469b55 --- /dev/null +++ b/test/integration/config/power-fan.spec.ts @@ -0,0 +1,577 @@ +import { expect } from 'chai'; +import { stripIndent } from 'common-tags'; +import { testfs } from 'mocha-pod'; +import type { SinonStub } from 'sinon'; + +import { PowerFanConfig } from '~/src/config/backends/power-fan'; +import { Extlinux } from '~/src/config/backends/extlinux'; +import { ExtraUEnv } from '~/src/config/backends/extra-uEnv'; +import { ConfigTxt } from '~/src/config/backends/config-txt'; +import { ConfigFs } from '~/src/config/backends/config-fs'; +import { Odmdata } from '~/src/config/backends/odmdata'; +import { SplashImage } from '~/src/config/backends/splash-image'; +import ConfigJsonConfigBackend from '~/src/config/configJson'; +import { schema } from '~/src/config/schema'; +import * as hostUtils from '~/lib/host-utils'; +import log from '~/lib/supervisor-console'; + +const SUPPORTED_DEVICE_TYPES = [ + 'jetson-agx-orin-devkit', + 'jetson-agx-orin-devkit-64gb', + 'jetson-orin-nano-devkit-nvme', + 'jetson-orin-nano-seeed-j3010', + 'jetson-orin-nx-seeed-j4012', + 'jetson-orin-nx-xavier-nx-devkit', +]; + +const UNSUPPORTED_DEVICE_TYPES = ['jetson-orin-nx-xv3']; + +describe('config/power-fan', () => { + const CONFIG_PATH = hostUtils.pathOnBoot('config.json'); + const generateConfigJsonBackend = () => new ConfigJsonConfigBackend(schema); + let powerFanConf: PowerFanConfig; + + beforeEach(async () => { + await testfs({ + '/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'), + }).enable(); + }); + + afterEach(async () => { + await testfs.restore(); + }); + + it('only matches supported devices', async () => { + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + for (const deviceType of SUPPORTED_DEVICE_TYPES) { + expect(await powerFanConf.matches(deviceType)).to.be.true; + } + + for (const deviceType of UNSUPPORTED_DEVICE_TYPES) { + expect(await powerFanConf.matches(deviceType)).to.be.false; + } + }); + + it('correctly gets boot configs from config.json', async () => { + const getConfigJson = (powerMode: string, fanProfile: string) => { + return stripIndent` + { + "os": { + "extra": "field", + "power": { + "mode": "${powerMode}" + }, + "fan": { + "profile": "${fanProfile}" + } + } + }`; + }; + + for (const powerMode of ['low', 'mid', 'high', 'custom_power']) { + for (const fanProfile of ['quiet', 'default', 'cool', 'custom_fan']) { + await testfs({ + [CONFIG_PATH]: getConfigJson(powerMode, fanProfile), + }).enable(); + + // ConfigJsonConfigBackend uses a cache, so setting a Supervisor-managed value + // directly in config.json (thus circumventing ConfigJsonConfigBackend) + // will not be reflected in the ConfigJsonConfigBackend instance. + // We need to create a new instance which will recreate the cache + // in order to get the latest value. + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + expect(await powerFanConf.getBootConfig()).to.deep.equal({ + power_mode: powerMode, + fan_profile: fanProfile, + }); + + await testfs.restore(); + } + } + }); + + it('correctly gets boot configs if power mode is not set', async () => { + await testfs({ + [CONFIG_PATH]: stripIndent` + { + "os": { + "extra": "field", + "fan": { + "profile": "quiet" + } + } + }`, + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + const config = await powerFanConf.getBootConfig(); + expect(config).to.deep.equal({ + fan_profile: 'quiet', + }); + }); + + it('correctly gets boot configs if fan profile is not set', async () => { + await testfs({ + [CONFIG_PATH]: stripIndent` + { + "os": { + "extra": "field", + "power": { + "mode": "low" + } + } + }`, + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + const config = await powerFanConf.getBootConfig(); + expect(config).to.deep.equal({ + power_mode: 'low', + }); + }); + + it('correctly gets boot configs if no relevant boot configs are set', async () => { + await testfs({ + [CONFIG_PATH]: stripIndent` + { + "os": { + "extra": "field" + } + }`, + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + const config = await powerFanConf.getBootConfig(); + expect(config).to.deep.equal({}); + }); + + it('ignores unrelated fields in config.json when getting boot configs', async () => { + const configStr = stripIndent` + { + "apiEndpoint": "https://api.balena-cloud.com", + "uuid": "deadbeef", + "os": { + "power": { + "mode": "low", + "extra": "field" + }, + "extra2": "field2", + "fan": { + "profile": "quiet", + "extra3": "field3" + }, + "network": { + "connectivity": { + "uri": "https://api.balena-cloud.com/connectivity-check", + "interval": "300", + "response": "optional value in the response" + }, + "wifi": { + "randomMacAddressScan": false + } + } + } + }`; + await testfs({ + [CONFIG_PATH]: configStr, + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + const config = await powerFanConf.getBootConfig(); + expect(config).to.deep.equal({ + power_mode: 'low', + fan_profile: 'quiet', + }); + + // Check that unrelated fields are unchanged + const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8'); + expect(configJson).to.equal(configStr); + }); + + it('gets boot configs in config.json while current config is empty', async () => { + await testfs({ + [CONFIG_PATH]: '{}', + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + expect(await powerFanConf.getBootConfig()).to.deep.equal({}); + }); + + it('sets boot configs in config.json', async () => { + await testfs({ + [CONFIG_PATH]: stripIndent` + { + "os": { + "extra": "field" + } + }`, + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + expect(await powerFanConf.getBootConfig()).to.deep.equal({}); + + await powerFanConf.setBootConfig({ + power_mode: 'low', + fan_profile: 'quiet', + }); + + expect(await powerFanConf.getBootConfig()).to.deep.equal({ + power_mode: 'low', + fan_profile: 'quiet', + }); + + // Sanity check that config.json is updated + const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8'); + expect(configJson).to.deep.equal( + JSON.stringify({ + os: { + extra: 'field', + power: { + mode: 'low', + }, + fan: { + profile: 'quiet', + }, + }, + }), + ); + }); + + it('sets boot configs in config.json while removing any unspecified boot configs', async () => { + await testfs({ + [CONFIG_PATH]: stripIndent` + { + "os": { + "extra": "field", + "power": { + "mode": "low" + } + } + }`, + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + await powerFanConf.setBootConfig({ + fan_profile: 'cool', + }); + + const config = await powerFanConf.getBootConfig(); + expect(config).to.deep.equal({ + fan_profile: 'cool', + }); + + // Sanity check that power mode is removed + const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8'); + expect(configJson).to.deep.equal( + JSON.stringify({ + os: { + extra: 'field', + fan: { + profile: 'cool', + }, + }, + }), + ); + }); + + it('sets boot configs in config.json while current config is empty', async () => { + await testfs({ + [CONFIG_PATH]: '{}', + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + await powerFanConf.setBootConfig({ + power_mode: 'low', + fan_profile: 'quiet', + }); + + expect(await powerFanConf.getBootConfig()).to.deep.equal({ + power_mode: 'low', + fan_profile: 'quiet', + }); + + // Sanity check that config.json is updated + const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8'); + expect(configJson).to.deep.equal( + JSON.stringify({ + os: { + power: { + mode: 'low', + }, + fan: { + profile: 'quiet', + }, + }, + }), + ); + }); + + it('sets boot configs in config.json while current and target config are empty', async () => { + await testfs({ + [CONFIG_PATH]: '{}', + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + await powerFanConf.setBootConfig({}); + + expect(await powerFanConf.getBootConfig()).to.deep.equal({}); + + // Sanity check that config.json is empty + const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8'); + expect(configJson).to.deep.equal(JSON.stringify({ os: {} })); + }); + + it('handles setting configs correctly when target configs are empty string', async () => { + await testfs({ + [CONFIG_PATH]: stripIndent` + { + "os": { + "extra": "field", + "power": { + "mode": "low" + } + } + }`, + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + await powerFanConf.setBootConfig({ + fan_profile: '', + }); + + const config = await powerFanConf.getBootConfig(); + expect(config).to.deep.equal({ + fan_profile: '', + }); + + // Sanity check that config.json is updated + const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8'); + expect(configJson).to.deep.equal( + JSON.stringify({ + os: { + extra: 'field', + fan: { + profile: '', + }, + }, + }), + ); + }); + + it('does not touch fields besides os.power and os.fan in config.json when setting boot configs', async () => { + await testfs({ + // Note that extra fields in os.power and os.fan are removed when setting, as os.power + // and os.fan are considered managed by the Supervisor. + [CONFIG_PATH]: stripIndent` + { + "apiEndpoint": "https://api.balena-cloud.com", + "uuid": "deadbeef", + "os": { + "power": { + "mode": "low", + "extra": "field" + }, + "extra2": "field2", + "fan": { + "profile": "quiet", + "extra3": "field3" + }, + "network": { + "connectivity": { + "uri": "https://api.balena-cloud.com/connectivity-check", + "interval": "300", + "response": "optional value in the response" + }, + "wifi": { + "randomMacAddressScan": false + } + } + } + }`, + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + await powerFanConf.setBootConfig({ + power_mode: 'high', + fan_profile: 'cool', + }); + + expect(await powerFanConf.getBootConfig()).to.deep.equal({ + power_mode: 'high', + fan_profile: 'cool', + }); + + // Sanity check that os.power and os.fan are updated + const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8'); + expect(configJson).to.deep.equal( + JSON.stringify({ + apiEndpoint: 'https://api.balena-cloud.com', + uuid: 'deadbeef', + os: { + power: { + // Extra fields in os.power are removed when setting + mode: 'high', + }, + extra2: 'field2', + fan: { + // Extra fields in os.fan are removed when setting + profile: 'cool', + }, + network: { + connectivity: { + uri: 'https://api.balena-cloud.com/connectivity-check', + interval: '300', + response: 'optional value in the response', + }, + wifi: { + randomMacAddressScan: false, + }, + }, + }, + }), + ); + }); + + it('does not touch fields besides os.power and os.fan in config.json when removing boot configs', async () => { + await testfs({ + [CONFIG_PATH]: stripIndent` + { + "apiEndpoint": "https://api.balena-cloud.com", + "uuid": "deadbeef", + "os": { + "power": { + "mode": "low", + "extra": "field" + }, + "extra2": "field2", + "fan": { + "profile": "quiet", + "extra3": "field3" + }, + "network": { + "connectivity": { + "uri": "https://api.balena-cloud.com/connectivity-check", + "interval": "300", + "response": "optional value in the response" + }, + "wifi": { + "randomMacAddressScan": false + } + } + } + }`, + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + await powerFanConf.setBootConfig({}); + + expect(await powerFanConf.getBootConfig()).to.deep.equal({}); + + // Sanity check that os.power and os.fan are removed + const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8'); + expect(configJson).to.deep.equal( + JSON.stringify({ + apiEndpoint: 'https://api.balena-cloud.com', + uuid: 'deadbeef', + os: { + extra2: 'field2', + network: { + connectivity: { + uri: 'https://api.balena-cloud.com/connectivity-check', + interval: '300', + response: 'optional value in the response', + }, + wifi: { + randomMacAddressScan: false, + }, + }, + }, + }), + ); + }); + + it('returns empty object with warning if config.json cannot be parsed', async () => { + await testfs({ + [CONFIG_PATH]: 'not json', + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + (log.error as SinonStub).resetHistory(); + + const config = await powerFanConf.getBootConfig(); + expect(config).to.deep.equal({}); + expect(log.error as SinonStub).to.have.been.calledWithMatch( + 'Failed to read config.json while getting power / fan configs:', + ); + }); + + it('returns empty object if boot config does not have the right schema', async () => { + await testfs({ + [CONFIG_PATH]: stripIndent` + { + "os": { + "power": "not an object", + "fan": "also not an object", + "extra": "field" + } + }`, + }).enable(); + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + expect(await powerFanConf.getBootConfig()).to.deep.equal({}); + }); + + it('is the only config backend that supports power mode and fan profile', () => { + const otherBackends = [ + new Extlinux(), + new ExtraUEnv(), + new ConfigTxt(), + new ConfigFs(), + new Odmdata(), + new SplashImage(), + ]; + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + + for (const config of ['power_mode', 'fan_profile']) { + for (const backend of otherBackends) { + expect(backend.isBootConfigVar(`HOST_CONFIG_${config}`)).to.be.false; + expect(backend.isSupportedConfig(config)).to.be.false; + } + + expect(powerFanConf.isBootConfigVar(`HOST_CONFIG_${config}`)).to.be.true; + expect(powerFanConf.isSupportedConfig(config)).to.be.true; + } + }); + + it('converts supported config vars to boot configs regardless of case', () => { + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + for (const config of ['power_mode', 'fan_profile']) { + expect( + powerFanConf.processConfigVarName(`HOST_CONFIG_${config}`), + ).to.equal(config); + expect( + powerFanConf.processConfigVarName( + `HOST_CONFIG_${config.toUpperCase()}`, + ), + ).to.equal(config); + } + }); + + it('allows any value for power mode and fan profile', () => { + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + for (const config of ['power_mode', 'fan_profile']) { + expect(powerFanConf.processConfigVarValue(config, 'any value')).to.equal( + 'any value', + ); + } + }); + + it('creates supported config vars from boot configs', () => { + powerFanConf = new PowerFanConfig(generateConfigJsonBackend()); + for (const config of ['power_mode', 'fan_profile']) { + expect(powerFanConf.createConfigVarName(config)).to.equal( + `HOST_CONFIG_${config}`, + ); + } + }); +}); diff --git a/test/integration/config/utils.spec.ts b/test/integration/config/utils.spec.ts index 4459a6dc..5f4d5b1c 100644 --- a/test/integration/config/utils.spec.ts +++ b/test/integration/config/utils.spec.ts @@ -7,6 +7,8 @@ import { Extlinux } from '~/src/config/backends/extlinux'; import { ConfigTxt } from '~/src/config/backends/config-txt'; import { ConfigFs } from '~/src/config/backends/config-fs'; import { SplashImage } from '~/src/config/backends/splash-image'; +import { PowerFanConfig } from '~/src/config/backends/power-fan'; +import { configJsonBackend } from '~/src/config'; import type { ConfigBackend } from '~/src/config/backends/backend'; import * as hostUtils from '~/lib/host-utils'; @@ -63,6 +65,7 @@ const BACKENDS: Record = { configtxt: new ConfigTxt(), configfs: new ConfigFs(), splashImage: new SplashImage(), + powerFan: new PowerFanConfig(configJsonBackend), }; const CONFIGS = { @@ -123,4 +126,14 @@ const CONFIGS = { // ssdt: ['spidev1,1'] // }, // }, + powerFan: { + envVars: { + HOST_CONFIG_power_mode: 'low', + HOST_CONFIG_fan_profile: 'quiet', + }, + bootConfig: { + power_mode: 'low', + fan_profile: 'quiet', + }, + }, };