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 write to
managed fields in the "os" key, in order to set power and fan
configs. In config.json, the expected managed schema is:
```
{
  os: {
    power: {
      mode: string
    },
    fan: {
      profile: string
    }
  }
}
```

There may be other keys in os which are not managed by the Supervisor,
so the Supervisor should not read or write to them.

After the Supervisor 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 (see next commit).

Change-type: minor
Signed-off-by: Christina Ying Wang <[email protected]>
  • Loading branch information
cywang117 committed Oct 22, 2024
1 parent 264a78f commit 28079ba
Show file tree
Hide file tree
Showing 5 changed files with 720 additions and 2 deletions.
15 changes: 13 additions & 2 deletions src/config/backends/config-txt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import _ from 'lodash';

import type { ConfigOptions } from './backend';
import { ConfigBackend } from './backend';
import { PowerFanConfig } from './power-fan';
import * as constants from '../../lib/constants';
import log from '../../lib/supervisor-console';
import { exists } from '../../lib/fs-utils';
Expand Down Expand Up @@ -231,11 +232,21 @@ export class ConfigTxt extends ConfigBackend {
}

public isSupportedConfig(configName: string): boolean {
return !ConfigTxt.forbiddenConfigKeys.includes(configName);
return (
!ConfigTxt.forbiddenConfigKeys.includes(configName) &&
// power_mode and fan_profile are managed by the power-fan backend, so
// need to be excluded here as the config var name prefix is the same.
!PowerFanConfig.isSupportedConfig(configName)
);
}

public isBootConfigVar(envVar: string): boolean {
return envVar.startsWith(ConfigTxt.bootConfigVarPrefix);
return (
envVar.startsWith(ConfigTxt.bootConfigVarPrefix) &&
// power_mode and fan_profile are managed by the power-fan backend, so
// need to be excluded here as the config var name prefix is the same.
!PowerFanConfig.isSupportedConfig(envVar)
);
}

public processConfigVarName(envVar: string): string {
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
160 changes: 160 additions & 0 deletions src/config/backends/power-fan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { isRight } from 'fp-ts/lib/Either';
import Reporter from 'io-ts-reporters';

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';
import type ConfigJsonConfigBackend from '../configJson';

const isNullOrUndefined = (v: unknown): v is null | undefined =>
v === null || v === undefined;

/**
* A backend to handle Jetson power and fan control
*
* Supports:
* - {BALENA|RESIN}_HOST_CONFIG_power_mode = "low" | "mid" | "high" | "$MODE_ID"
* - {BALENA|RESIN}_HOST_CONFIG_fan_profile = "quiet" | "default" | "cool" | "$MODE_ID"
*/
export class PowerFanConfig extends ConfigBackend {
private static readonly PREFIX = `${constants.hostConfigVarPrefix}CONFIG_`;
private static readonly CONFIGS = new Set(['power_mode', 'fan_profile']);

private readonly configJson: ConfigJsonConfigBackend;
public constructor(configJson: ConfigJsonConfigBackend) {
super();
this.configJson = configJson;
}

private 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 relevant 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 known schema from unknown type
const powerFanConfig = schemaTypes.os.type.decode(rawConf);

if (isRight(powerFanConfig)) {
const conf = powerFanConfig.right;
return {
...(!isNullOrUndefined(conf.power?.mode) && {
power_mode: conf.power.mode,
}),
...(!isNullOrUndefined(conf.fan?.profile) && {
fan_profile: conf.fan.profile,
}),
};
} else {
return {};
}
}

public async setBootConfig(opts: ConfigOptions): Promise<void> {
// Read power & fan boot configs from config.json
let rawConf: unknown;
try {
rawConf = await this.configJson.get('os');
} catch (err: unknown) {
log.error(`${(err as Error).message ?? err}`);
return;
}

// Decode from unknown to known schema
const decodedCurrentConf = schemaTypes.os.type.decode(rawConf);
if (!isRight(decodedCurrentConf)) {
log.error(
'Failed to decode current power & fan config:',
Reporter.report(decodedCurrentConf),
);
return;
}
const currentConf = decodedCurrentConf.right;

// Filter out unsupported options
const supportedOpts = Object.fromEntries(
Object.entries(opts).filter(([key]) => this.isSupportedConfig(key)),
) as { power_mode?: string; fan_profile?: string };

const targetConf = structuredClone(currentConf);

// Iterate over supported configs Set and update targetConf
// Cast key to only members of PowerFanConfig.CONFIGS to avoid TypeScript errors
// const key should be of type 'power_mode' or 'fan_profile' ONLY

// Update or delete power mode
if ('power_mode' in supportedOpts) {
targetConf.power = {
...targetConf.power,
mode: supportedOpts.power_mode,
};
} else {
delete targetConf.power;
}

// Update or delete fan profile
if ('fan_profile' in supportedOpts) {
targetConf.fan = {
...targetConf.fan,
profile: supportedOpts.fan_profile,
};
} else {
delete targetConf.fan;
}

await this.configJson.set({ os: targetConf });
}

public isSupportedConfig(name: string): boolean {
return PowerFanConfig.CONFIGS.has(PowerFanConfig.stripPrefix(name));
}

// A static version of isSupportedConfig for other backends to use to exclude power & fan configs
public static 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}`;
}
}
Loading

0 comments on commit 28079ba

Please sign in to comment.