Merge pull request #2379 from balena-os/support-jetson-power-fan-configs

Support jetson power fan configs
This commit is contained in:
flowzone-app[bot] 2024-12-10 23:27:26 +00:00 committed by GitHub
commit e085013548
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1031 additions and 41 deletions

View File

@ -41,6 +41,12 @@ export abstract class ConfigBackend {
// Example an empty string should return null.
public abstract createConfigVarName(configName: string): string | null;
// Is a reboot required for the given config options?
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async isRebootRequired(_opts: ConfigOptions): Promise<boolean> {
return true;
}
// Allow a chosen config backend to be initialised
public async initialise(): Promise<ConfigBackend> {
return this;

View File

@ -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<boolean> {
return (
@ -109,11 +110,8 @@ export class ConfigTxt extends ConfigBackend {
public async getBootConfig(): Promise<ConfigOptions> {
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

View File

@ -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 {

View File

@ -0,0 +1,170 @@
import { isRight } from 'fp-ts/lib/Either';
import Reporter from 'io-ts-reporters';
import * as t from 'io-ts';
import * as _ from 'lodash';
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<unknown>;
set: (opts: { os: Record<string, any> }) => Promise<void>;
};
/**
* 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<boolean> {
// 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<ConfigOptions> {
// 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<void> {
// 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 async isRebootRequired(opts: ConfigOptions): Promise<boolean> {
const supportedOpts = _.pickBy(
_.mapKeys(opts, (_value, key) => PowerFanConfig.stripPrefix(key)),
(_value, key) => this.isSupportedConfig(key),
);
const current = await this.getBootConfig();
// A reboot is only required if the power mode is changing
return current.power_mode !== supportedOpts.power_mode;
}
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}`;
}
}

View File

@ -3,7 +3,6 @@ import _ from 'lodash';
import * as constants from '../lib/constants';
import * as hostUtils from '../lib/host-utils';
import * as osRelease from '../lib/os-release';
import { takeGlobalLockRO, takeGlobalLockRW } from '../lib/process-lock';
import type * as Schema from './schema';
@ -12,17 +11,20 @@ export default class ConfigJsonConfigBackend {
private readonly writeLockConfigJson: () => Bluebird.Disposer<() => void>;
private readonly schema: Schema.Schema;
/**
* @deprecated configPath is only set by legacy tests
*/
private readonly configPath?: string;
private cache: { [key: string]: unknown } = {};
private readonly init = _.once(async () =>
Object.assign(this.cache, await this.read()),
);
private readonly init = _.once(async () => {
Object.assign(this.cache, await this.read());
});
public constructor(schema: Schema.Schema, configPath?: string) {
this.configPath = configPath;
this.schema = schema;
this.configPath = configPath;
this.writeLockConfigJson = () =>
takeGlobalLockRW('config.json').disposer((release) => release());
@ -37,14 +39,10 @@ export default class ConfigJsonConfigBackend {
await Bluebird.using(this.writeLockConfigJson(), async () => {
let changed = false;
_.forOwn(keyVals, (value, key: T) => {
if (this.cache[key] !== value) {
if (this.schema[key] != null && !_.isEqual(this.cache[key], value)) {
this.cache[key] = value;
if (
value == null &&
this.schema[key] != null &&
this.schema[key].removeIfNull
) {
if (value == null && this.schema[key].removeIfNull) {
delete this.cache[key];
}
@ -57,15 +55,14 @@ export default class ConfigJsonConfigBackend {
});
}
public async get(key: string): Promise<unknown> {
public async get(key: Schema.SchemaKey): Promise<unknown> {
await this.init();
return Bluebird.using(
this.readLockConfigJson(),
async () => this.cache[key],
return Bluebird.using(this.readLockConfigJson(), async () =>
structuredClone(this.cache[key]),
);
}
public async remove(key: string) {
public async remove(key: Schema.SchemaKey) {
await this.init();
return Bluebird.using(this.writeLockConfigJson(), async () => {
let changed = false;
@ -91,6 +88,12 @@ export default class ConfigJsonConfigBackend {
return JSON.parse(await hostUtils.readFromBoot(filename, 'utf-8'));
}
/**
* @deprecated Either read the config.json path from lib/constants, or
* pass a validated path to the constructor and fail if no path is passed.
* TODO: Remove this once api-binder tests are migrated. The only
* time configPath is passed to the constructor is in the legacy tests.
*/
private async path(): Promise<string> {
// TODO: Remove this once api-binder tests are migrated. The only
// time configPath is passed to the constructor is in the legacy tests.
@ -98,11 +101,6 @@ export default class ConfigJsonConfigBackend {
return this.configPath;
}
const osVersion = await osRelease.getOSVersion(constants.hostOSVersionPath);
if (osVersion == null) {
throw new Error('Failed to detect OS version!');
}
// The default path in the boot partition
return constants.configJsonPath;
}

View File

@ -84,6 +84,10 @@ export const schemaTypes = {
type: t.string,
default: NullOrUndefined,
},
os: {
type: t.union([t.record(t.string, t.any), t.undefined]),
default: NullOrUndefined,
},
// Database types
name: {

View File

@ -84,6 +84,11 @@ export const schema = {
mutable: false,
removeIfNull: false,
},
os: {
source: 'config.json',
mutable: true,
removeIfNull: false,
},
name: {
source: 'db',

View File

@ -529,18 +529,21 @@ async function getBackendSteps(
const { deviceType } = await config.getMany(['deviceType']);
// Check for required bootConfig changes
let rebootRequired = false;
for (const backend of backends) {
if (changeRequired(backend, current, target, deviceType)) {
steps.push({
action: 'setBootConfig',
target,
});
rebootRequired =
(await backend.isRebootRequired(target)) || rebootRequired;
}
}
return [
// All backend steps require a reboot
...(steps.length > 0
// All backend steps require a reboot except fan control
...(steps.length > 0 && rebootRequired
? [{ action: 'setRebootBreadcrumb' } as ConfigStep]
: []),
...steps,

View File

@ -0,0 +1,203 @@
import { expect } from 'chai';
import type { TestFs } from 'mocha-pod';
import { testfs } from 'mocha-pod';
import { promises as fs } from 'fs';
import ConfigJsonConfigBackend from '~/src/config/configJson';
import { schema } from '~/src/config/schema';
describe('ConfigJsonConfigBackend', () => {
const CONFIG_PATH = '/mnt/boot/config.json';
const os = {
power: {
mode: 'high',
},
fan: {
profile: 'cool',
},
network: {
connectivity: {
uri: 'https://api.balena-cloud.com/connectivity-check',
interval: '300',
response: 'optional value in the response',
},
wifi: {
randomMacAddressScan: false,
},
},
udevRules: {
'56': 'ENV{ID_FS_LABEL_ENC}=="resin-root*", IMPORT{program}="resin_update_state_probe $devnode", SYMLINK+="disk/by-state/$env{RESIN_UPDATE_STATE}"',
'64': 'ACTION!="add|change", GOTO="modeswitch_rules_end"\nKERNEL=="ttyACM*", ATTRS{idVendor}=="1546", ATTRS{idProduct}=="1146", TAG+="systemd", ENV{SYSTEMD_WANTS}="u-blox-switch@\'%E{DEVNAME}\'.service"\nLBEL="modeswitch_rules_end"\n',
},
sshKeys: [
'ssh-rsa AAAAB3Nza...M2JB balena@macbook-pro',
'ssh-rsa AAAAB3Nza...nFTQ balena@zenbook',
],
};
let configJsonConfigBackend: ConfigJsonConfigBackend;
let tfs: TestFs.Enabled;
beforeEach(() => {
configJsonConfigBackend = new ConfigJsonConfigBackend(schema);
});
afterEach(async () => {
await tfs.restore();
});
it('should get primitive values for config.json key', async () => {
tfs = await testfs({
[CONFIG_PATH]: JSON.stringify({
apiEndpoint: 'foo',
deviceId: 123,
persistentLogging: true,
}),
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
}).enable();
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('foo');
expect(await configJsonConfigBackend.get('deviceId')).to.equal(123);
expect(await configJsonConfigBackend.get('persistentLogging')).to.equal(
true,
);
});
it('should get object values for config.json "os" key', async () => {
tfs = await testfs({
[CONFIG_PATH]: JSON.stringify({
os,
}),
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
}).enable();
expect(await configJsonConfigBackend.get('os')).to.deep.equal(os);
});
it('should get object values for config.json "os" key while "os" is empty', async () => {
tfs = await testfs({
[CONFIG_PATH]: JSON.stringify({}),
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
}).enable();
expect(await configJsonConfigBackend.get('os')).to.be.undefined;
});
it('should set primitive values for config.json key', async () => {
tfs = await testfs({
[CONFIG_PATH]: JSON.stringify({
apiEndpoint: 'foo',
deviceId: 123,
persistentLogging: true,
}),
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
}).enable();
await configJsonConfigBackend.set({
apiEndpoint: 'bar',
deviceId: 456,
persistentLogging: false,
});
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('bar');
expect(await configJsonConfigBackend.get('deviceId')).to.equal(456);
expect(await configJsonConfigBackend.get('persistentLogging')).to.equal(
false,
);
});
it('should set object values for config.json "os" key', async () => {
tfs = await testfs({
[CONFIG_PATH]: JSON.stringify({
os,
}),
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
}).enable();
const newOs = {
power: {
mode: 'low',
},
network: {
wifi: {
randomMacAddressScan: true,
},
},
udevRules: {
'56': 'ENV{ID_FS_LABEL_ENC}=="resin-root*", IMPORT{program}="resin_update_state_probe $devnode", SYMLINK+="disk/by-state/$env{RESIN_UPDATE_STATE}"',
},
sshKeys: ['ssh-rsa AAAAB3Nza...M2JB balena@macbook-pro'],
};
await configJsonConfigBackend.set({
os: newOs,
});
expect(await configJsonConfigBackend.get('os')).to.deep.equal(newOs);
});
it('should set object values for config.json "os" key while "os" is empty', async () => {
tfs = await testfs({
[CONFIG_PATH]: JSON.stringify({}),
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
}).enable();
await configJsonConfigBackend.set({
os,
});
expect(await configJsonConfigBackend.get('os')).to.deep.equal(os);
});
// The following test cases may be unnecessary as they test cases where another party
// writes to config.json directly (instead of through setting config vars on the API).
it('should get cached value even if actual value has changed', async () => {
tfs = await testfs({
[CONFIG_PATH]: JSON.stringify({
apiEndpoint: 'foo',
}),
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
}).enable();
// The cached value should be returned
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('foo');
// Change the value in the file
await fs.writeFile(
CONFIG_PATH,
JSON.stringify({
apiEndpoint: 'bar',
}),
);
// Unintended behavior: the cached value should not be overwritten
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('foo');
});
it('should set value and refresh cache to equal new value', async () => {
tfs = await testfs({
[CONFIG_PATH]: JSON.stringify({
apiEndpoint: 'foo',
}),
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
}).enable();
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('foo');
await fs.writeFile(
CONFIG_PATH,
JSON.stringify({
apiEndpoint: 'bar',
}),
);
// Unintended behavior: cached value should not have been updated
// as the change was not written to config.json by the Supervisor
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('foo');
await configJsonConfigBackend.set({
apiEndpoint: 'baz',
});
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('baz');
});
});

View File

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

View File

@ -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<string, ConfigBackend> = {
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',
},
},
};