Skip to content
Merged
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,38 @@ echo "$temp"

Remember to make the file executable (`chmod +x`).

## Fan identity and USB port changes

For USB controllers that expose a unique serial number, fan labels and
configurations are identified by VID:PID, serial number, interface, and PWM
channel. The plugin records this identity when a label or configuration is
saved, and automatically upgrades existing entries at service startup while
their controller can still be identified. Labels use an encoded `usb:` key in
`pwm_labels.cfg`; `controller_identity` stores the same key in each fan
configuration, or a path binding when no unique serial is available. The
runtime `controller` path is resolved again whenever the service starts.

This preserves assignments when a controller moves to another USB port, even
when an identical model occupies the old port. Missing or ambiguous identities
(including duplicate serial numbers for the same channel) remain saved but
unassigned until they can be resolved; the plugin does not guess another device.

Controllers without a serial number, controllers whose serial is already
duplicated when first saved, and older entries whose serial has not yet been
recorded use the same-port path migration. Their existing port assignments
remain separate. Keep those controllers on the same port. If an unrecorded
controller has already moved, reassign it once so its serial can be saved.
Restart the service after a driver reload or port move. If multiple
configurations resolve to one channel, the already-current assignment wins;
blocked bindings remain saved for recovery when the conflicting assignment
is removed.
Saving a configuration without a resolved controller, including a rename,
retains its binding. Selecting a different controller replaces the binding.
Clearing a label also removes its dormant USB identity entry, so it cannot
reappear after serial ambiguity is resolved.
The identity identifies the controller's header, not the physical fan attached
to it; replacing the fan does not change the saved assignment.

Support / Issues
- https://github.com/andrebrait/fanctrlplus/issues

Expand Down
213 changes: 160 additions & 53 deletions src/usr/local/emhttp/plugins/fanctrlplus2/include/Common.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,6 @@ function normalize_chip_name(string $chip): string {
return $chip;
}

function build_pwm_map(): array {
$map = [];
foreach (list_pwm() as $pwm) {
if ($pwm['chip'] === '') continue;
$map[normalize_chip_name($pwm['chip']).':'.$pwm['name']] = $pwm['sensor'];
}
return $map;
}

function extract_chip_and_pwm_from_path(string $old_path): ?array {
$old_path = trim($old_path, " \t\n\r\0\x0B\"'");

Expand Down Expand Up @@ -124,12 +115,92 @@ function extract_chip_and_pwm_from_path(string $old_path): ?array {
return null;
}

// Find where a saved /sys/devices pwm path lives now. The hwmonN index follows
// probe order and a USB HID device gets a new instance suffix
// (0003:VID:PID.NNNN) each time it enumerates, so both are wildcarded; the
// rest of the path (bus topology, VID:PID, pwmN) still pins the same device.
const FCP_HID_INSTANCE = '#(/[0-9A-Fa-f]{4}:[0-9A-Fa-f]{4}:[0-9A-Fa-f]{4})\.[0-9A-Fa-f]+/#';

function find_moved_pwm_path(string $old_path): ?string {
// A /sys/class path carries no device identity; glob characters would
// turn the saved path into an arbitrary pattern.
if (strpos($old_path, '/class/hwmon/') !== false || strpbrk($old_path, '*?[') !== false) return null;
if (file_exists($old_path)) return $old_path;
$pattern = preg_replace(
['#/hwmon/hwmon\d+/#', FCP_HID_INSTANCE],
['/hwmon/hwmon*/', '$1.*/'],
$old_path
);
$matches = glob($pattern) ?: [];
return count($matches) === 1 ? $matches[0] : null;
}

// Path-only entries cannot recover a serial after the device has moved.
// Keep the same-port migration for those legacy/no-serial entries.
function current_pwm_path(string $old_path, array $pwm_map): ?string {
$moved = find_moved_pwm_path($old_path);
if ($moved !== null || preg_match(FCP_HID_INSTANCE, $old_path) || preg_match('#/usb\d+/#', $old_path)) return $moved;
$pair = extract_chip_and_pwm_from_path($old_path);
return $pair ? ($pwm_map[$pair[0].':'.$pair[1]] ?? null) : null;
}

// A fan is a USB controller plus its interface/hwmon channel, not a USB port.
// Hex-encoded JSON is safe as an unquoted label key and a shell/INI value.
function fcp_pwm_identity(string $path): ?string {
$path = realpath($path);
if ($path === false || !preg_match('#/pwm\d+$#', $path)) return null;
for ($dir = dirname($path); $dir !== dirname($dir); $dir = dirname($dir)) {
if (!is_file("$dir/idVendor") || !is_file("$dir/idProduct")) continue;
$vid = strtolower(trim((string)@file_get_contents("$dir/idVendor")));
$pid = strtolower(trim((string)@file_get_contents("$dir/idProduct")));
$serial = trim((string)@file_get_contents("$dir/serial"));
if (!preg_match('/^[0-9a-f]{4}$/', $vid) || !preg_match('/^[0-9a-f]{4}$/', $pid) || $serial === '') return null;
$channel = substr($path, strlen($dir));
$channel = preg_replace('#^/[^/]+:(\d+\.\d+)/#', '/interface:$1/', $channel);
$channel = preg_replace([FCP_HID_INSTANCE, '#/hwmon/hwmon\d+/#'], ['$1.*/', '/hwmon/hwmon*/'], $channel);
$encoded = json_encode([$vid, $pid, $serial, $channel]);
return $encoded === false ? null : 'usb:'.bin2hex($encoded);
}
return null;
}

function fcp_pwm_identity_map(array $pwms): array {
$map = [];
foreach ($pwms as $pwm) {
$key = fcp_pwm_identity($pwm['sensor']);
if ($key === null) continue;
// Duplicate serials/channels cannot safely identify either device.
$map[$key] = array_key_exists($key, $map) ? null : $pwm['sensor'];
}
return $map;
}

// Capture a serial key only while it uniquely identifies this channel.
// Devices with duplicated/placeholder serials retain their port identity.
function fcp_unique_pwm_identity(string $path, ?array $identities = null): ?string {
$key = fcp_pwm_identity($path);
if ($key === null) return null;
$identities = $identities ?? fcp_pwm_identity_map(list_pwm());
return ($identities[$key] ?? null) === realpath($path) ? $key : null;
}

function fcp_load_pwm_labels(string $file, string $hwmon_glob = '/sys/class/hwmon/hwmon*'): array {
$identities = fcp_pwm_identity_map(list_pwm($hwmon_glob));
$labels = [];
foreach (is_file($file) ? file($file, FILE_IGNORE_NEW_LINES) : [] as $line) {
if (!preg_match('/^(.+?)=(.+)$/', $line, $m)) continue;
$path = strpos($m[1], 'usb:') === 0 ? ($identities[$m[1]] ?? null) : $m[1];
if ($path !== null && preg_match('#/pwm\d+$#', $path)) $labels[$path] = $m[2];
}
return $labels;
}

function log_migrate(string $msg): void {
// Write the plugin log.
@file_put_contents("/var/log/fanctrlplus2-migrate.log",
date("c")." ".$msg."\n", FILE_APPEND);
// Mirror the entry to syslog.
@exec("logger -t fanctrlplus2 '$msg'");
@exec('logger -t fanctrlplus2 ' . escapeshellarg($msg));
}

function safe_rewrite(string $file, string $content): bool {
Expand All @@ -142,81 +213,117 @@ function safe_rewrite(string $file, string $content): bool {
return true;
}

function migrate_cfg_and_labels(string $plugin): void {
$cfgpath = "/boot/config/plugins/$plugin";
function migrate_cfg_and_labels(string $plugin, string $cfgpath = '', string $hwmon_glob = '/sys/class/hwmon/hwmon*'): void {
$cfgpath = $cfgpath ?: "/boot/config/plugins/$plugin";
$labelFile = "$cfgpath/pwm_labels.cfg";
$pwm_map = build_pwm_map();
$pwms = list_pwm($hwmon_glob);
$pwm_map = [];
foreach ($pwms as $pwm) $pwm_map[normalize_chip_name($pwm['chip']).':'.$pwm['name']] = $pwm['sensor'];
$identities = fcp_pwm_identity_map($pwms);

// --- labels ---
if (is_file($labelFile)) {
$lines = file($labelFile, FILE_IGNORE_NEW_LINES) ?: [];
$changed = false; $out = [];
// Several lines can land on one path: a label re-entered for the
// current path after the device moved, or stale ones from several
// moves. Keep one line, as the readers see it (the last one), except
// that a line saved for the current path beats any migrated one.
$changed = false; $out = []; $pos = []; $current = [];
foreach ($lines as $line) {
if (!preg_match('/^(.+?)=(.*)$/', $line, $m)) { $out[]=$line; continue; }
$old_path = trim($m[1], " \t\n\r\0\x0B\"'");
$label = $m[2];

if (preg_match('/^__FCP_[A-Z0-9_]+__$/', $old_path)) {
// Flags, and empty labels, which the readers ignore.
if (preg_match('/^__FCP_[A-Z0-9_]+__$/', $old_path) || $label === '') {
$out[] = $line;
continue;
}

$pair = extract_chip_and_pwm_from_path($old_path);
if (!$pair) { log_migrate("migrate label: skip (unparsable) $old_path"); $out[]=$line; continue; }
[$chip,$pwmN] = $pair;
$key = "$chip:$pwmN";
if (!isset($pwm_map[$key])) { log_migrate("migrate label: no match for $chip:$pwmN, keep $old_path"); $out[]=$line; continue; }

$new_path = $pwm_map[$key];
if ($new_path !== $old_path) {
$stable = strpos($old_path, 'usb:') === 0;
$new_path = $stable ? ($identities[$old_path] ?? null) : current_pwm_path($old_path, $pwm_map);
if ($new_path === null) { log_migrate("migrate label: no match, keep $old_path"); $out[]=$line; continue; }
$is_current = $stable || $new_path === $old_path;
$key = fcp_unique_pwm_identity($new_path, $identities) ?? $new_path;
if ($key !== $old_path) $changed = true;
if (!$is_current) {
$changed = true;
if (preg_match('#/(hwmon\d+)/#', $old_path, $o) && preg_match('#/(hwmon\d+)/#', $new_path, $n)) {
log_migrate("migrate label: $old_path → $new_path ({$o[1]} → {$n[1]})");
} else {
log_migrate("migrate label: $old_path → $new_path");
}
$changed = true;
$out[] = $new_path.'='.$label;
}
if (!isset($pos[$key])) {
$pos[$key] = count($out);
$out[] = $key.'='.$label;
} else {
$out[] = $line;
$changed = true;
if (!$is_current && isset($current[$key])) {
log_migrate("migrate label: drop $old_path, $new_path already labeled");
continue;
}
$out[$pos[$key]] = $key.'='.$label;
}
if ($is_current) $current[$key] = true;
}
if ($changed) safe_rewrite($labelFile, implode("\n", $out));
}

// --- cfgs ---
foreach (glob("$cfgpath/{$plugin}_*.cfg") ?: [] as $cfgfile) {
// A fan assigned to the current path while an old one still pointed at
// the vanished path keeps it: two loops must not drive the same PWM.
$cfgfiles = glob("$cfgpath/{$plugin}_*.cfg") ?: [];
$configs = []; $in_use = [];
foreach ($cfgfiles as $cfgfile) {
$ini = @parse_ini_file($cfgfile);
if (!$ini) continue;

$configs[$cfgfile] = $ini;
$path = (string)($ini['controller'] ?? '');
$identity = (string)($ini['controller_identity'] ?? '');
$source = $identity !== '' ? $identity : $path;
$resolved = strpos($source, 'usb:') === 0 ? ($identities[$source] ?? null) : (is_file($source) ? $source : null);
// Already-current manual assignments win over stale migrated ones.
if ($resolved === $path && $path !== '' && !isset($in_use[$path])) $in_use[$path] = $cfgfile;
}
foreach ($configs as $cfgfile => $ini) {
$original = $ini;
$old_path = trim((string)($ini['controller'] ?? ''), " \t\n\r\0\x0B\"'");

if ($old_path === '' || !preg_match('#/hwmon\d+/pwm\d+$#', $old_path)) {
continue;
}

$pair = extract_chip_and_pwm_from_path($old_path);
if (!$pair) {
log_migrate("migrate cfg: skip (unparsable) $cfgfile controller=$old_path");
continue;
$identity = (string)($ini['controller_identity'] ?? '');
if ($identity === '' && !preg_match('#/pwm\d+$#', $old_path)) continue;
$source = $identity !== '' ? $identity : $old_path;
$new_path = strpos($source, 'usb:') === 0 ? ($identities[$source] ?? null) : current_pwm_path($source, $pwm_map);
if ($new_path !== null && isset($in_use[$new_path]) && $in_use[$new_path] !== $cfgfile) {
log_migrate("migrate cfg: keep $old_path in $cfgfile, $new_path is already controlled");
if ($identity === '' && !is_file($old_path)) continue;
// A live losing path must also stop. Retain its binding for
// recovery when the winning assignment is removed.
if ($identity === '') $identity = fcp_unique_pwm_identity($old_path, $identities) ?? $old_path;
$ini['controller_identity'] = $identity;
$new_path = null;
}
[$chip,$pwmN] = $pair;
$key = "$chip:$pwmN";
if (!isset($pwm_map[$key])) {
log_migrate("migrate cfg: no match for $cfgfile ($chip:$pwmN), keep $old_path");
continue;
}

$new_path = $pwm_map[$key];
if ($new_path === $old_path) continue;

if (preg_match('#/(hwmon\d+)/#', $old_path, $o) && preg_match('#/(hwmon\d+)/#', $new_path, $n)) {
log_migrate("migrate cfg: $cfgfile controller: $old_path → $new_path ({$o[1]} → {$n[1]})");
if ($new_path === null) {
log_migrate("migrate cfg: no match for $cfgfile, identity=$identity");
// Never drive a replacement at the old path. Keep the identity
// so a disconnected/ambiguous controller can recover later.
if ($identity === '') continue;
$ini['controller'] = '';
} else {
log_migrate("migrate cfg: $cfgfile controller: $old_path → $new_path");
$in_use[$new_path] = $cfgfile;
$ini['controller'] = $new_path;
$ini['controller_identity'] = fcp_unique_pwm_identity($new_path, $identities) ?? $new_path;
if ($new_path !== $old_path) log_migrate("migrate cfg: $cfgfile controller: $old_path → $new_path");
}
if ($ini === $original) continue;
// Only these two fields changed. Re-serializing the entire INI would
// re-escape free-text values that are also sourced by the shell.
$buf = (string)file_get_contents($cfgfile);
foreach (['controller', 'controller_identity'] as $k) {
$v = str_replace(["\\", '"', '$', '`', "\r", "\n"], ["\\\\", '\\"', '\\$', '\\`', '', ''], (string)$ini[$k]);
$line = "$k=\"$v\"";
$buf = preg_replace_callback('/^[ \t]*'.$k.'[ \t]*=.*$/m', function () use ($line) { return $line; }, $buf, -1, $count);
if ($count === 0) $buf = rtrim($buf, "\n")."\n".$line."\n";
}

$ini['controller'] = $new_path;
$buf=''; foreach ($ini as $k=>$v){ $v=str_replace('"','',(string)$v); $buf.=$k.'="'.$v."\"\n"; }
safe_rewrite($cfgfile, $buf);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
<?php
require_once __DIR__.'/Common.php';
$label_file = "/boot/config/plugins/fanctrlplus2/pwm_labels.cfg";
$pwm_labels = [];
if (is_file($label_file)) {
foreach (file($label_file, FILE_IGNORE_NEW_LINES) as $line) {
if (preg_match('/^(.+?)=(.+)$/', $line, $m)) {
$pwm_labels[$m[1]] = $m[2];
}
}
}
$pwm_labels = fcp_load_pwm_labels($label_file);

// Build the list of disk groups for a fan cfg: [{name, disks:[...], low, high}, ...].
// disk_group_count>0 -> read disk_group_{g}_* keys. Otherwise fall back to ONE group
Expand Down
46 changes: 11 additions & 35 deletions src/usr/local/emhttp/plugins/fanctrlplus2/include/FanctrlLogic.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,38 +110,21 @@ function scan_dir($dir) {
$pwm = $_POST['pwm'] ?? '';
$label = $_POST['label'] ?? '';

$label_file = "/boot/config/plugins/fanctrlplus2/pwm_labels.cfg";
// Read existing labels.
$lines = is_file($label_file) ? file($label_file, FILE_IGNORE_NEW_LINES) : [];
$found = false;

if (!$pwm) {
json_response(['status' => 'error', 'message' => 'Missing pwm']);
break;
}

// An empty label means delete it.
if ($label === '') {
$new_lines = [];
foreach ($lines as $line) {
if (strpos($line, "$pwm=") !== 0) $new_lines[] = $line;
}
file_put_contents($label_file, implode("\n", $new_lines) . "\n");
json_response(['status' => 'ok', 'message' => 'Label removed']);
break;
}

// Write the label.
foreach ($lines as &$line) {
if (strpos($line, "$pwm=") === 0) {
$line = "$pwm=$label";
$found = true;
break;
}
}
if (!$found) $lines[] = "$pwm=$label";
$identity = fcp_pwm_identity($pwm);
$key = fcp_unique_pwm_identity($pwm) ?? $pwm;
$lines = is_file($label_file) ? file($label_file, FILE_IGNORE_NEW_LINES) : [];
// Remove both the legacy path and stable key before saving one entry.
$lines = array_values(array_filter($lines, function ($line) use ($key, $pwm, $identity, $label) {
return strpos($line, "$key=") !== 0 && strpos($line, "$pwm=") !== 0
&& ($label !== '' || $identity === null || strpos($line, "$identity=") !== 0);
}));
if ($label !== '') $lines[] = "$key=$label";
file_put_contents($label_file, implode("\n", $lines) . "\n");
json_response(['status' => 'ok', 'message' => 'Label saved']);
json_response(['status' => 'ok', 'message' => $label === '' ? 'Label removed' : 'Label saved']);
break;

case 'newtemp':
Expand Down Expand Up @@ -324,14 +307,7 @@ function scan_dir($dir) {
case 'getpwm':
$pwms = list_pwm();
$label_file = "/boot/config/plugins/fanctrlplus2/pwm_labels.cfg";
$labels = [];
if (is_file($label_file)) {
foreach (file($label_file, FILE_IGNORE_NEW_LINES) as $line) {
if (preg_match('/^(.+?)=(.+)$/', $line, $m)) {
$labels[$m[1]] = $m[2];
}
}
}
$labels = fcp_load_pwm_labels($label_file);
foreach ($pwms as &$pwm) {
$pwm['label'] = $labels[$pwm['sensor']] ?? '';
}
Expand Down
Loading
Loading