From 28079ba187180bbe4a29b887f3d7f10db24ac899 Mon Sep 17 00:00:00 2001 From: Christina Ying Wang Date: Sat, 19 Oct 2024 00:08:17 -0700 Subject: [PATCH] Add PowerFanConfig config backend This config backend uses ConfigJsonConfigBackend to write to managed fields in the "os" key, in order to set power and fan configs. In config.json, the expected managed schema is: ``` { os: { power: { mode: string }, fan: { profile: string } } } ``` There may be other keys in os which are not managed by the Supervisor, so the Supervisor should not read or write to them. After the Supervisor writes to config.json, host services os-power-mode and os-fan-profile pick up the changes, on reboot in the former's case and at runtime in the latter's case. The changes are applied by the host services, which the Supervisor does not manage aside from streaming their service logs to the dashboard (see next commit). Change-type: minor Signed-off-by: Christina Ying Wang --- src/config/backends/config-txt.ts | 15 +- src/config/backends/index.ts | 3 + src/config/backends/power-fan.ts | 160 +++++++ test/integration/config/power-fan.spec.ts | 531 ++++++++++++++++++++++ test/integration/config/utils.spec.ts | 13 + 5 files changed, 720 insertions(+), 2 deletions(-) create mode 100644 src/config/backends/power-fan.ts create mode 100644 test/integration/config/power-fan.spec.ts 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', + }, + }, };