Skip to content

Commit

Permalink
Add PowerFanConfig config backend
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
cywang117 committed Dec 10, 2024
1 parent 54fcfa2 commit 828bd22
Show file tree
Hide file tree
Showing 6 changed files with 780 additions and 21 deletions.
44 changes: 26 additions & 18 deletions src/config/backends/config-txt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 (
Expand All @@ -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 {};
}
Expand Down Expand Up @@ -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[] {
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/config/backends/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -12,6 +14,7 @@ export const allBackends = [
new ConfigFs(),
new Odmdata(),
new SplashImage(),
new PowerFanConfig(configJsonBackend),
];

export function matchesAnyBootConfig(envVar: string): boolean {
Expand Down
159 changes: 159 additions & 0 deletions src/config/backends/power-fan.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
}
5 changes: 2 additions & 3 deletions src/config/configJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
);
}

Expand Down
Loading

0 comments on commit 828bd22

Please sign in to comment.