mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-18 13:26:26 +00:00
Add PowerFanConfig config backend
This config backend uses ConfigJsonConfigBackend to update os.power and os.fan subfields under the "os" key, in order to set power and fan configs. The expected format for os.power and os.fan settings is: ``` { os: { power: { mode: string }, fan: { profile: string } } } ``` There may be other keys in os which are not managed by the Supervisor, so PowerFanConfig backend doesn't read or write to them. Extra keys in os.power and os.fan are ignored when getting boot config and removed when setting boot config. After this backend 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. Change-type: minor Signed-off-by: Christina Ying Wang <christina@balena.io>
This commit is contained in:
parent
54fcfa22a7
commit
828bd22ba0
@ -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
|
||||
|
@ -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 {
|
||||
|
159
src/config/backends/power-fan.ts
Normal file
159
src/config/backends/power-fan.ts
Normal file
@ -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<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 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}`;
|
||||
}
|
||||
}
|
@ -57,9 +57,8 @@ export default class ConfigJsonConfigBackend {
|
||||
|
||||
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]),
|
||||
);
|
||||
}
|
||||
|
||||
|
577
test/integration/config/power-fan.spec.ts
Normal file
577
test/integration/config/power-fan.spec.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user