diff --git a/src/config/backends/config-txt.ts b/src/config/backends/config-txt.ts index 4eb46f9ad..ec576f6e4 100644 --- a/src/config/backends/config-txt.ts +++ b/src/config/backends/config-txt.ts @@ -2,6 +2,7 @@ import _ from 'lodash'; import type { ConfigOptions } from './backend'; import { ConfigBackend } from './backend'; +import { PowerFanConfig } from './power-fan'; import * as constants from '../../lib/constants'; import log from '../../lib/supervisor-console'; import { exists } from '../../lib/fs-utils'; @@ -231,11 +232,21 @@ export class ConfigTxt extends ConfigBackend { } public isSupportedConfig(configName: string): boolean { - return !ConfigTxt.forbiddenConfigKeys.includes(configName); + return ( + !ConfigTxt.forbiddenConfigKeys.includes(configName) && + // power_mode and fan_profile are managed by the power-fan backend, so + // need to be excluded here as the config var name prefix is the same. + !PowerFanConfig.isSupportedConfig(configName) + ); } public isBootConfigVar(envVar: string): boolean { - return envVar.startsWith(ConfigTxt.bootConfigVarPrefix); + return ( + envVar.startsWith(ConfigTxt.bootConfigVarPrefix) && + // power_mode and fan_profile are managed by the power-fan backend, so + // need to be excluded here as the config var name prefix is the same. + !PowerFanConfig.isSupportedConfig(envVar) + ); } public processConfigVarName(envVar: string): string { diff --git a/src/config/backends/index.ts b/src/config/backends/index.ts index cab6fbcf5..31955e0fc 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 000000000..0a98805b6 --- /dev/null +++ b/src/config/backends/power-fan.ts @@ -0,0 +1,160 @@ +import { isRight } from 'fp-ts/lib/Either'; +import Reporter from 'io-ts-reporters'; + +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'; +import type ConfigJsonConfigBackend from '../configJson'; + +const isNullOrUndefined = (v: unknown): v is null | undefined => + v === null || v === undefined; + +/** + * A backend to handle Jetson power and fan control + * + * Supports: + * - {BALENA|RESIN}_HOST_CONFIG_power_mode = "low" | "mid" | "high" | "$MODE_ID" + * - {BALENA|RESIN}_HOST_CONFIG_fan_profile = "quiet" | "default" | "cool" | "$MODE_ID" + */ +export class PowerFanConfig extends ConfigBackend { + private static readonly PREFIX = `${constants.hostConfigVarPrefix}CONFIG_`; + private static readonly CONFIGS = new Set(['power_mode', 'fan_profile']); + + private readonly configJson: ConfigJsonConfigBackend; + public constructor(configJson: ConfigJsonConfigBackend) { + super(); + this.configJson = configJson; + } + + private 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 relevant 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 known schema from unknown type + const powerFanConfig = schemaTypes.os.type.decode(rawConf); + + if (isRight(powerFanConfig)) { + const conf = powerFanConfig.right; + return { + ...(!isNullOrUndefined(conf.power?.mode) && { + power_mode: conf.power.mode, + }), + ...(!isNullOrUndefined(conf.fan?.profile) && { + fan_profile: conf.fan.profile, + }), + }; + } else { + return {}; + } + } + + public async setBootConfig(opts: ConfigOptions): Promise { + // Read power & fan boot configs from config.json + let rawConf: unknown; + try { + rawConf = await this.configJson.get('os'); + } catch (err: unknown) { + log.error(`${(err as Error).message ?? err}`); + return; + } + + // Decode from unknown to known schema + const decodedCurrentConf = schemaTypes.os.type.decode(rawConf); + if (!isRight(decodedCurrentConf)) { + log.error( + 'Failed to decode current power & fan config:', + Reporter.report(decodedCurrentConf), + ); + return; + } + const currentConf = decodedCurrentConf.right; + + // Filter out unsupported options + const supportedOpts = Object.fromEntries( + Object.entries(opts).filter(([key]) => this.isSupportedConfig(key)), + ) as { power_mode?: string; fan_profile?: string }; + + const targetConf = structuredClone(currentConf); + + // Iterate over supported configs Set and update targetConf + // Cast key to only members of PowerFanConfig.CONFIGS to avoid TypeScript errors + // const key should be of type 'power_mode' or 'fan_profile' ONLY + + // Update or delete power mode + if ('power_mode' in supportedOpts) { + targetConf.power = { + ...targetConf.power, + mode: supportedOpts.power_mode, + }; + } else { + delete targetConf.power; + } + + // Update or delete fan profile + if ('fan_profile' in supportedOpts) { + targetConf.fan = { + ...targetConf.fan, + profile: supportedOpts.fan_profile, + }; + } else { + delete targetConf.fan; + } + + await this.configJson.set({ os: targetConf }); + } + + public isSupportedConfig(name: string): boolean { + return PowerFanConfig.CONFIGS.has(PowerFanConfig.stripPrefix(name)); + } + + // A static version of isSupportedConfig for other backends to use to exclude power & fan configs + public static 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/test/integration/config/power-fan.spec.ts b/test/integration/config/power-fan.spec.ts new file mode 100644 index 000000000..6056e0d68 --- /dev/null +++ b/test/integration/config/power-fan.spec.ts @@ -0,0 +1,531 @@ +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, PROTECTED_FIELDS } 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, PROTECTED_FIELDS); + 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('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 config.json is updated + const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8'); + expect(configJson).to.deep.equal( + JSON.stringify({ + os: { + extra: 'field', + power: {}, + fan: { + profile: 'cool', + }, + }, + }), + ); + }); + + it('handles setting configs correctly when target configs are empty string BBB', 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', + power: {}, + fan: { + profile: '', + }, + }, + }), + ); + }); + + it('does not touch unmanaged fields in config.json when setting 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({ + power_mode: 'high', + fan_profile: 'cool', + }); + + expect(await powerFanConf.getBootConfig()).to.deep.equal({ + power_mode: 'high', + fan_profile: 'cool', + }); + + // Sanity check that config.json is unchanged + 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: { + mode: 'high', + extra: 'field', + }, + extra2: 'field2', + fan: { + profile: 'cool', + extra3: 'field3', + }, + 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 unmanaged fields 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 config.json is unchanged + 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: 'field', + }, + extra2: 'field2', + fan: { + extra3: 'field3', + }, + 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()); + + const config = await powerFanConf.getBootConfig(); + expect(config).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(`HOST_CONFIG_${config}`)).to.be.false; + } + + expect(powerFanConf.isBootConfigVar(`HOST_CONFIG_${config}`)).to.be.true; + expect(powerFanConf.isSupportedConfig(`HOST_CONFIG_${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 4459a6dcf..5f4d5b1c9 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', + }, + }, };