Skip to content
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
38 changes: 29 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,15 @@ Do not reinstall or run upstream FanCtrl Plus while FanCtrl Plus 2 is installed.

## Custom temperature sensors

Any sensor the plugin does not know about can be added as a script. Drop an
executable file in:
Any sensor the plugin does not know about can be added as a readable script with
a valid shebang. Place the file in:

```text
/boot/config/plugins/fanctrlplus2/sensors.d/
```

Each executable in that directory becomes one auxiliary sensor, named after the
file, selectable per fan alongside the built-in sources. The contract is:
Each readable script in that directory becomes one auxiliary sensor, named
after the file, selectable per fan alongside the built-in sources. The contract is:

- print the temperature in degrees Celsius and nothing else (a fractional
reading is truncated to whole degrees);
Expand All @@ -79,10 +79,9 @@ driven by its other sources, and the script is tried again on the next round.
Errors are reported by the exit status, never by a sentinel reading: a script
that prints `0` has reported 0 °C.

Readings are clamped to 0–200 °C, as they are for every other source, so a
value outside that range drives the fan at the corresponding end of its curve
rather than being discarded. A script that reads 0 °C when the settings page
is opened is taken for a dead sensor and is not offered in the list.
Readings below −100 °C or above 300 °C are rejected as implausible; valid negative
readings are floored at 0 °C. A script that reads 0 °C when the settings page is
opened is taken for an unpopulated sensor and is not offered in the list.

```sh
#!/bin/bash
Expand All @@ -92,7 +91,28 @@ temp=$(some-tool --read-ambient 2>/dev/null) || exit 1
echo "$temp"
```

Remember to make the file executable (`chmod +x`).
The flash drive does not need executable permissions. The plugin makes a private
executable copy on runtime storage, preserving the shebang and using the file's
current contents on each read. The copy is removed after the reading finishes.
Use absolute paths for helper files: `$0` refers to the runtime copy, not the
original flash-resident script.

## Configuration backup and restore

Use **General Settings → Export backup** to download saved fan configurations,
PWM labels, dashboard switches, and fan ordering as a JSON file. Unsaved edits,
custom sensor scripts, runtime history, and logs are not included.

**Restore backup** asks for confirmation before replacing saved settings. The
entire file is validated before changes are made, and unrelated files such as
`sensors.d/` remain untouched. Fan control retains its prior running or stopped
state; a failed replacement restores the previous managed configuration files.
Reloading after a successful restore shows the restored settings.

Store backups somewhere other than the Unraid flash drive. Back up custom sensor
scripts separately. Restored USB identities are resolved by the existing device
migration at service startup; a missing or ambiguous recorded controller is not
assigned to another device.

## Fan identity and USB port changes

Expand Down
101 changes: 94 additions & 7 deletions src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ HTML;

<?PHP
$cfg_dir = "/boot/config/plugins/$plugin";
require_once "$docroot/plugins/$plugin/include/ConfigBackup.php";
$page_config_lock = fcp_config_lock($cfg_dir);

// Load the shared fan-block renderer.
require_once "$docroot/plugins/$plugin/include/FanBlockRender.php";
Expand Down Expand Up @@ -133,8 +135,6 @@ foreach ([false, true] as $temporary) {
$pwms = list_pwm();
$disks = list_valid_disks_by_id();
$width = 300;
$cpu_sensors = detect_cpu_sensors();
$aux_sensors = detect_aux_sensors();

// Read a dashboard-tile switch (e.g. __FCP_AIRFLOW__, __FCP_HISTORY__).
function fcp_labels_flag_enabled(string $cfg_dir, string $flag): bool {
Expand All @@ -152,6 +152,10 @@ function fcp_labels_flag_enabled(string $cfg_dir, string $flag): bool {

$fcp_airflow_enabled = fcp_labels_flag_enabled($cfg_dir, '__FCP_AIRFLOW__');
$fcp_history_enabled = fcp_labels_flag_enabled($cfg_dir, '__FCP_HISTORY__');
flock($page_config_lock, LOCK_UN);
fclose($page_config_lock);
$cpu_sensors = detect_cpu_sensors();
$aux_sensors = detect_aux_sensors();
?>

<div class="fanctrl-header">
Expand Down Expand Up @@ -779,7 +783,7 @@ $(function() {
if (pwm.label) {
display += '(' + pwm.label + ')';
}
select.append(`<option value="${pwm.sensor}">${display}</option>`);
select.append($('<option>').val(pwm.sensor).text(display));
});
}).fail(function(jqXHR, textStatus, errorThrown) {
console.error('Failed to load PWM list into ' + selector, textStatus, errorThrown);
Expand Down Expand Up @@ -828,8 +832,8 @@ $(function() {
if (pwm.label) display += `(${pwm.label})`;

// Apply the selected state.
const isSelected = (selectedPWM && pwm.sensor === selectedPWM) ? ' selected' : '';
select.append(`<option value="${pwm.sensor}"${isSelected}>${display}</option>`);
select.append($('<option>').val(pwm.sensor).text(display)
.prop('selected', Boolean(selectedPWM && pwm.sensor === selectedPWM)));
});

// Preserve selectedPWM when no matching option was discovered.
Expand Down Expand Up @@ -931,7 +935,8 @@ $(function() {
const isSelected = pwm.sensor === selected;
const isUsed = usedPWMs.includes(pwm.sensor) && !isSelected;

$(this).append(`<option value="${pwm.sensor}" ${isSelected ? 'selected' : ''} ${isUsed ? 'disabled' : ''}>${display}</option>`);
$(this).append($('<option>').val(pwm.sensor).text(display)
.prop({selected: isSelected, disabled: isUsed}));
});
});

Expand Down Expand Up @@ -1353,6 +1358,76 @@ $(function() {
enabled: checked ? 1 : 0
});
}

const configBackupUrl = '/plugins/fanctrlplus2/include/FanctrlLogic.php';
function setConfigBackupBusy(busy) {
$('#fcp-config-export, #fcp-config-import').prop('disabled', busy);
}
function configBackupError(xhr) {
$('#fcp-config-backup-status').text(
xhr.responseJSON && xhr.responseJSON.message
? xhr.responseJSON.message : 'The configuration request failed.'
);
}

$(document).on('click', '#fcp-config-export', function () {
setConfigBackupBusy(true);
$('#fcp-config-backup-status').text('Preparing backup…');
$.ajax({url: configBackupUrl, data: {op: 'exportconfig'}, dataType: 'json'})
.done(function (snapshot) {
const url = URL.createObjectURL(new Blob([JSON.stringify(snapshot, null, 2)], {type: 'application/json'}));
const link = document.createElement('a');
link.href = url;
link.download = 'fanctrlplus2-config-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
$('#fcp-config-backup-status').text('Backup downloaded. Only saved settings are included.');
}).fail(configBackupError).always(() => setConfigBackupBusy(false));
});

$(document).on('click', '#fcp-config-import', function () {
document.getElementById('fcp-config-import-file').click();
});

$(document).on('change', '#fcp-config-import-file', async function () {
const file = this.files[0];
this.value = '';
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
$('#fcp-config-backup-status').text('The backup exceeds the 2 MiB limit.');
return;
}
const confirmation = await Swal.fire({
title: 'Restore saved configuration?',
text: 'This replaces saved fan settings, labels and ordering. Unsaved edits will be discarded. Custom sensor scripts stay unchanged, and the running or stopped state is preserved.',
icon: 'warning',
showCancelButton: true,
focusCancel: true,
confirmButtonText: 'Restore',
cancelButtonText: 'Cancel'
});
if (!confirmation.isConfirmed) return;
setConfigBackupBusy(true);
$('#fcp-config-backup-status').text('Restoring configuration…');
try {
const backup = await file.text();
JSON.parse(backup);
$.ajax({url: configBackupUrl, type: 'POST', data: {op: 'importconfig', backup: backup}, dataType: 'json'})
.done(function (response) {
if (response.status !== 'ok') {
$('#fcp-config-backup-status').text(response.message || 'Restore failed.');
return;
}
$('#fcp-config-backup-status').text('Configuration restored. Reloading…');
window.location.reload();
}).fail(configBackupError).always(() => setConfigBackupBusy(false));
} catch (error) {
$('#fcp-config-backup-status').text('The selected file is not a readable JSON backup.');
setConfigBackupBusy(false);
}
});

$(document).on('click', '.add-disk-group-btn', function () {
const $btn = $(this);
Expand Down Expand Up @@ -1720,7 +1795,7 @@ $(function() {
<p><strong>Auxiliary Sensor Settings:</strong>
Works the same way as Disk and CPU Temperature Settings, for anything that is neither: chipset, VRM, network card, RAID controller, GPU.<br>
<strong>Include Sensor(s)</strong> lists the non-CPU, non-NVMe hwmon sensors, and adds LSI/MegaRAID controllers (storcli), NVIDIA GPUs (nvidia-smi) and Mellanox network cards (mget_temp) when those tools are installed. The highest temperature among the selected sensors drives this fan.<br>
<strong>Custom sensors:</strong> anything else can be added as a script. Any executable placed in <code>/boot/config/plugins/fanctrlplus2/sensors.d/</code> becomes a selectable sensor named after the file. It must print the temperature in degrees Celsius and nothing else, and on any error print nothing and exit with a non-zero status. A script that errors, prints something else, or takes longer than 5&nbsp;seconds is skipped for that round of checks only, and tried again on the next one. Readings from every source, this one included, are clamped to 0–200&nbsp;°C, so a value outside that range drives the fan at the corresponding end of its curve instead of being discarded.
<strong>Custom sensors:</strong> add a readable script with a valid shebang to <code>/boot/config/plugins/fanctrlplus2/sensors.d/</code>. It becomes a selectable sensor named after the file. No executable bit is needed on the flash drive: the plugin runs a private executable copy on runtime storage using the script’s own interpreter. The script must print only a temperature in degrees Celsius, or exit non-zero on error. Errors, invalid output and scripts taking longer than 5&nbsp;seconds are skipped for that round only. Outputs outside the plausible −100–300&nbsp;°C range are skipped; valid negative readings are floored at 0&nbsp;°C.
</p>

<p><strong>Chart:</strong> Click the <strong>Chart</strong> button in a fan block to preview the fan speed curve based on your current settings.
Expand Down Expand Up @@ -1963,6 +2038,18 @@ $(function() {
While disabled, no fan-speed history is collected.
</blockquote>
</div>

<div class="fcp-section">
<p><strong>Configuration backup</strong></p>
<p>Download saved fan settings, PWM labels, dashboard switches and ordering.
Restoring replaces those settings. Custom sensor scripts are not included.</p>
<div class="fcp-actions">
<button type="button" class="btn" id="fcp-config-export">Export backup</button>
<button type="button" class="btn" id="fcp-config-import" aria-controls="fcp-config-import-file">Restore backup</button>
<input type="file" id="fcp-config-import-file" accept=".json,application/json" aria-label="Choose a configuration backup" hidden>
</div>
<p id="fcp-config-backup-status" role="status" aria-live="polite"></p>
</div>
</div>

<!-- Syslog settings. -->
Expand Down
19 changes: 13 additions & 6 deletions src/usr/local/emhttp/plugins/fanctrlplus2/include/Common.php
Original file line number Diff line number Diff line change
Expand Up @@ -447,9 +447,10 @@ function fcp_clamp_temp(int $temp): ?int {
}

// ===== User-supplied sensor scripts =====
// Any executable dropped in the sensors.d directory is a sensor. The contract
// is deliberately small: print the temperature in Celsius and nothing else, or
// print nothing and exit non-zero, which disables it for that round only.
// Any readable file dropped in the sensors.d directory is a sensor, executable
// or not: the directory sits on flash that cannot hold an execute bit. The
// contract is deliberately small: print the temperature in Celsius and nothing
// else, or print nothing and exit non-zero, which disables it for that round only.
const FCP_CUSTOM_SENSOR_DIR = '/boot/config/plugins/fanctrlplus2/sensors.d';
const FCP_CUSTOM_SENSOR_TIMEOUT = 5;

Expand All @@ -469,16 +470,22 @@ function detect_custom_temps(string $dir = FCP_CUSTOM_SENSOR_DIR): array {
$result = [];
$scripts = glob("$dir/*") ?: [];
sort($scripts);
$runner = dirname(__DIR__) . '/scripts/custom_sensor.sh';

foreach ($scripts as $script) {
$name = basename($script);
if (!is_file($script) || !is_executable($script)) continue;
if ($name[0] === '.') continue;
// Same names the control loop can read back: no hidden files, no
// characters that would not survive the sensor list.
if (!preg_match('/^[A-Za-z0-9_-][A-Za-z0-9._-]*$/', $name)) continue;
if (!is_file($script) || !is_readable($script)) continue;

// The flash drive holding the directory cannot carry an execute bit, so
// the runner executes a private runtime copy and enforces the timeout.
$output = [];
$status = 0;
exec(
'timeout ' . FCP_CUSTOM_SENSOR_TIMEOUT . ' ' . escapeshellarg($script) . ' 2>/dev/null',
'bash ' . escapeshellarg($runner) . ' ' . escapeshellarg($script) . ' '
. FCP_CUSTOM_SENSOR_TIMEOUT . ' 2>/dev/null',
$output,
$status
);
Expand Down
Loading
Loading