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}`, ); } }); });