Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support jetson power fan configs #2379

Merged
merged 4 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
Comment on lines +149 to +157
Copy link
Contributor

@pipex pipex Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was it decided to bother with this? Before, all host config changes would require a reboot. Even if it is not necessary for the fan profile, it doesn't seem worth it the extra complexity just to avoid a reboot in this particular case.

Copy link
Contributor Author

@cywang117 cywang117 Dec 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebooting when only changing the fan control runtime config is bad UX for the user as it causes unnecessary disruption to devices as well as storage wear. A host config being tied to something that requires a reboot isn't a very strong tie in my opinion; there certainly exists the concept of a runtime OS configuration even if we don't support it in the Supervisor yet. Some examples of this include swappiness and other kernel runtime parameters via sysctl, network configuration such as iptables, and hardware-specific runtime configs: motor speed, sensor sensitivity, etc..

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does it cause storage wear? How often do we expect users to be changing the fan control speed?

I agree that there are use cases for changing configurations without a reboot.

I don't think the config variable interface is great for that. Config variables require an API call and a target state pull before the changes are applied, is not an interface made for runtime requirements.

If there is need for such an interface then we should work on that rather than trying to fit the config var interface for a specific use case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I think this feature has exposed some limits of the way we handle configs on top of what we were aware of before.


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)) {
cywang117 marked this conversation as resolved.
Show resolved Hide resolved
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
Loading