Skip to content

Commit

Permalink
Merge pull request #2379 from balena-os/support-jetson-power-fan-configs
Browse files Browse the repository at this point in the history
Support jetson power fan configs
  • Loading branch information
flowzone-app[bot] authored Dec 10, 2024
2 parents a2c9f55 + 2f2b2e1 commit e085013
Show file tree
Hide file tree
Showing 11 changed files with 1,031 additions and 41 deletions.
6 changes: 6 additions & 0 deletions src/config/backends/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
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
170 changes: 170 additions & 0 deletions src/config/backends/power-fan.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
}
40 changes: 19 additions & 21 deletions src/config/configJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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());
Expand All @@ -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];
}

Expand All @@ -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;
Expand All @@ -91,18 +88,19 @@ 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.
if (this.configPath != null) {
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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/config/schema-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading

0 comments on commit e085013

Please sign in to comment.