diff --git a/src/components/layout/AppSettingsNav.vue b/src/components/layout/AppSettingsNav.vue
index b2b3d2b450..01125d38d7 100644
--- a/src/components/layout/AppSettingsNav.vue
+++ b/src/components/layout/AppSettingsNav.vue
@@ -45,6 +45,7 @@ export default class AppSettingsNav extends Vue {
{ name: this.$tc('app.setting.title.camera', 2), hash: '#camera', visible: true },
{ name: this.$t('app.setting.title.tool'), hash: '#toolhead', visible: true },
{ name: this.$t('app.setting.title.thermal_presets'), hash: '#presets', visible: true },
+ { name: this.$t('app.setting.title.aliases'), hash: '#aliases', visible: true },
{ name: this.$t('app.setting.title.gcode_preview'), hash: '#gcodePreview', visible: true },
{ name: this.$t('app.general.title.timelapse'), hash: '#timelapse', visible: this.supportsTimelapse },
{ name: this.$t('app.mmu.title.headline'), hash: '#mmu', visible: this.supportsMmu },
diff --git a/src/components/settings/AliasSettings.vue b/src/components/settings/AliasSettings.vue
new file mode 100644
index 0000000000..947b673a7b
--- /dev/null
+++ b/src/components/settings/AliasSettings.vue
@@ -0,0 +1,112 @@
+
+
+
+ {{ $t('app.setting.title.aliases') }}
+
+
+
+
+ {{ $t('app.setting.tooltip.aliases') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/widgets/outputs/OutputFan.vue b/src/components/widgets/outputs/OutputFan.vue
index 9556e1c291..98d7991976 100644
--- a/src/components/widgets/outputs/OutputFan.vue
+++ b/src/components/widgets/outputs/OutputFan.vue
@@ -5,7 +5,7 @@
suffix="%"
:value="value"
:reset-value="0"
- :label="(rpm) ? `${fan.prettyName} ${rpm}` : fan.prettyName"
+ :label="label"
:rules="[
customRules.minFan
]"
@@ -43,13 +43,20 @@ import { Component, Mixins, Prop } from 'vue-property-decorator'
import type { Fan } from '@/store/printer/types'
import StateMixin from '@/mixins/state'
import BrowserMixin from '@/mixins/browser'
-import { encodeGcodeParamValue } from '@/util/gcode-helpers'
+import buildOutputLabel from '@/util/build-output-label'
+import { buildFanSpeedGcode } from '@/util/output-gcode'
@Component({})
export default class OutputFan extends Mixins(StateMixin, BrowserMixin) {
@Prop({ type: Object, required: true })
readonly fan!: Fan
+ // prettyName may be a user-supplied alias; the label is rendered as HTML
+ // (v-safe-html) so escape it before composing the optional rpm markup.
+ get label () {
+ return buildOutputLabel(this.fan.prettyName, this.rpm)
+ }
+
get prettyValue () {
return (this.value === 0)
? this.$t('app.general.label.off')
@@ -63,14 +70,11 @@ export default class OutputFan extends Mixins(StateMixin, BrowserMixin) {
}
handleChange (target: number) {
- // If this is a controllable fan, it's either the part fan [fan] or a generic fan [fan_generic].
- if (this.fan.type === 'fan') {
- target = Math.ceil(target * 2.55)
- this.sendGcode(`M106 S${target}`, `${this.$waits.onSetFanSpeed}${this.fan.name}`)
- }
- if (this.fan.type === 'fan_generic') {
- target = target / 100
- this.sendGcode(`SET_FAN_SPEED FAN=${encodeGcodeParamValue(this.fan.name)} SPEED=${target}`, `${this.$waits.onSetFanSpeed}${this.fan.name}`)
+ // Display-only: the command is keyed by the raw Klipper name, never the alias.
+ const gcode = buildFanSpeedGcode(this.fan, target)
+
+ if (gcode) {
+ this.sendGcode(gcode, `${this.$waits.onSetFanSpeed}${this.fan.name}`)
}
}
diff --git a/src/components/widgets/outputs/OutputPin.vue b/src/components/widgets/outputs/OutputPin.vue
index ed541e54f6..ceedf77353 100644
--- a/src/components/widgets/outputs/OutputPin.vue
+++ b/src/components/widgets/outputs/OutputPin.vue
@@ -3,7 +3,7 @@
diff --git a/src/components/widgets/thermals/ThermalChart.vue b/src/components/widgets/thermals/ThermalChart.vue
index 78639b3fff..7af4d92d41 100644
--- a/src/components/widgets/thermals/ThermalChart.vue
+++ b/src/components/widgets/thermals/ThermalChart.vue
@@ -43,6 +43,7 @@ import { Component, Watch, Prop, Ref, Mixins } from 'vue-property-decorator'
import type { ECharts, EChartsInitOpts, EChartsOption, LineSeriesOption } from 'echarts'
import getKlipperType from '@/util/get-klipper-type'
import BrowserMixin from '@/mixins/browser'
+import { formatThermalTooltip } from './thermal-tooltip-formatter'
import type { ChartData, ChartSelectedLegends } from '@/store/charts/types'
@Component({})
@@ -104,6 +105,10 @@ export default class ThermalChart extends Mixins(BrowserMixin) {
return this.$typedState.config.uiSettings.dashboard.sensorColors
}
+ get aliases (): Record {
+ return this.$typedState.config.uiSettings.dashboard.aliases
+ }
+
@Watch('sensorColors', { deep: true })
onSensorColorsChange () {
if (!this.chart) return
@@ -267,48 +272,14 @@ export default class ThermalChart extends Mixins(BrowserMixin) {
obj[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)]] = 10
return obj
},
- formatter: (params) => {
- if (!Array.isArray(params)) {
- return ''
- }
-
- let text = ''
- params
- .forEach((param: any) => {
- if (
- param.seriesName &&
- !param.seriesName.endsWith('#target') &&
- !param.seriesName.endsWith('#power') &&
- !param.seriesName.endsWith('#speed') &&
- param.value[param.seriesName] != null
- ) {
- const name = param.seriesName.trim().split(/\s+/).pop() || ''
- text += `
-
- ${param.marker}
-
- ${this.$filters.prettyCase(name)}:
-
-
- ${param.value[param.seriesName].toFixed(2)}°C`
-
- if (param.value[`${param.seriesName}#target`] != null) {
- text += ` / ${param.value[`${param.seriesName}#target`].toFixed()}°C`
- }
- if (param.value[`${param.seriesName}#power`] != null) {
- text += ` / ${(param.value[`${param.seriesName}#power`] * 100).toFixed()}%`
- }
- if (param.value[`${param.seriesName}#speed`] != null) {
- text += ` / ${(param.value[`${param.seriesName}#speed`] * 100).toFixed()}%`
- }
- text += `
-
-
- `
- }
- })
- return text
- }
+ // Reads the live `aliases` object per-hover so in-place Vue.set/Vue.delete
+ // mutations are reflected without rebuilding the chart options.
+ formatter: (params) => formatThermalTooltip(params, {
+ aliases: this.aliases,
+ prettyCase: (value: string) => this.$filters.prettyCase(value),
+ fontColor,
+ fontSize
+ })
},
xAxis: {
type: 'time',
diff --git a/src/components/widgets/thermals/__tests__/thermalchart-tooltip.spec.ts b/src/components/widgets/thermals/__tests__/thermalchart-tooltip.spec.ts
new file mode 100644
index 0000000000..ad38657c75
--- /dev/null
+++ b/src/components/widgets/thermals/__tests__/thermalchart-tooltip.spec.ts
@@ -0,0 +1,44 @@
+import { formatThermalTooltip, type ThermalTooltipContext } from '../thermal-tooltip-formatter'
+
+const KEY = 'temperature_sensor chamber'
+
+const ctx = (aliases: Record = {}): ThermalTooltipContext => ({
+ aliases,
+ prettyCase: (v: string) => `p:${v}`,
+ fontColor: '#000',
+ fontSize: 14,
+})
+
+const param = (overrides: Record = {}) => ({
+ seriesName: KEY,
+ marker: '',
+ value: { [KEY]: 42.5 },
+ ...overrides,
+})
+
+describe('formatThermalTooltip', () => {
+ it('returns empty string for non-array params', () => {
+ expect(formatThermalTooltip({}, ctx())).toBe('')
+ })
+
+ it('shows the alias in the tooltip when one is set (AC1)', () => {
+ const html = formatThermalTooltip([param()], ctx({ [KEY]: 'Chamber' }))
+ expect(html).toContain('Chamber:')
+ })
+
+ it('falls back to the default label for an EMPTY-string alias (|| not ??)', () => {
+ const html = formatThermalTooltip([param()], ctx({ [KEY]: '' }))
+ expect(html).toContain('p:chamber:')
+ })
+
+ it('HTML-escapes a malicious alias — no live markup reaches the sink (AC6)', () => {
+ const html = formatThermalTooltip([param()], ctx({ [KEY]: '
' }))
+ expect(html).toContain('<img src=x onerror=alert(1)>')
+ expect(html).not.toContain('
{
+ const html = formatThermalTooltip([param()], ctx())
+ expect(html).toContain('42.50')
+ })
+})
diff --git a/src/components/widgets/thermals/thermal-tooltip-formatter.ts b/src/components/widgets/thermals/thermal-tooltip-formatter.ts
new file mode 100644
index 0000000000..d540151fcc
--- /dev/null
+++ b/src/components/widgets/thermals/thermal-tooltip-formatter.ts
@@ -0,0 +1,66 @@
+import resolveAliasLabel from '@/util/resolve-alias-label'
+
+export interface ThermalTooltipContext {
+ // Live alias map; read per-hover so in-place Vue.set/Vue.delete stay reflected.
+ aliases: Record
+ prettyCase: (value: string) => string
+ fontColor: string
+ fontSize: number
+}
+
+/**
+ * Build the ECharts tooltip HTML for the thermal chart.
+ *
+ * Extracted from ThermalChart so the alias-resolution + HTML-escape wiring
+ * (AC1 tooltip + AC6 XSS) is unit-testable without mounting the component.
+ * The series label is resolved via `resolveAliasLabel`, which applies the user
+ * alias (keyed by the full `seriesName`) AND escapes it — escaping alone would
+ * show the auto name; both steps are required.
+ */
+export const formatThermalTooltip = (params: unknown, ctx: ThermalTooltipContext): string => {
+ if (!Array.isArray(params)) {
+ return ''
+ }
+
+ const { aliases, prettyCase, fontColor, fontSize } = ctx
+
+ let text = ''
+
+ params.forEach((param: any) => {
+ if (
+ param.seriesName &&
+ !param.seriesName.endsWith('#target') &&
+ !param.seriesName.endsWith('#power') &&
+ !param.seriesName.endsWith('#speed') &&
+ param.value[param.seriesName] != null
+ ) {
+ const name = param.seriesName.trim().split(/\s+/).pop() || ''
+ const label = resolveAliasLabel(param.seriesName, aliases, prettyCase(name))
+
+ text += `
+
+ ${param.marker}
+
+ ${label}:
+
+
+ ${param.value[param.seriesName].toFixed(2)}°C`
+
+ if (param.value[`${param.seriesName}#target`] != null) {
+ text += ` / ${param.value[`${param.seriesName}#target`].toFixed()}°C`
+ }
+ if (param.value[`${param.seriesName}#power`] != null) {
+ text += ` / ${(param.value[`${param.seriesName}#power`] * 100).toFixed()}%`
+ }
+ if (param.value[`${param.seriesName}#speed`] != null) {
+ text += ` / ${(param.value[`${param.seriesName}#speed`] * 100).toFixed()}%`
+ }
+ text += `
+
+
+ `
+ }
+ })
+
+ return text
+}
diff --git a/src/locales/en.yaml b/src/locales/en.yaml
index ef1738b8eb..30160ccd65 100644
--- a/src/locales/en.yaml
+++ b/src/locales/en.yaml
@@ -723,6 +723,7 @@ app:
'270': 270°
none: None
label:
+ aliases: Custom display names
all_off: All off
all_on: All on
aspect_ratio: Aspect Ratio
@@ -867,6 +868,7 @@ app:
slicer: Slicer
slicer_m73: Slicer (M73)
title:
+ aliases: Custom Names
authentication: Authentication
console: Console
camera: Camera | Cameras
@@ -880,6 +882,9 @@ app:
tool: Tool
warnings: Warnings
tooltip:
+ aliases: Give fans, output pins, LEDs, heaters and temperature sensors friendly
+ display names. This only changes what Fluidd shows — the Klipper
+ configuration and object names are left untouched.
average_calculation: If more than one option is select, an average will be
calculated
diagnostics_performance: '[BETA] Logging diagnostics info may impact performance'
diff --git a/src/store/config/__tests__/aliases.spec.ts b/src/store/config/__tests__/aliases.spec.ts
new file mode 100644
index 0000000000..713c1eb401
--- /dev/null
+++ b/src/store/config/__tests__/aliases.spec.ts
@@ -0,0 +1,108 @@
+import { vi } from 'vitest'
+import { SocketActions } from '@/api/socketActions'
+import { mutations } from '../mutations'
+import { actions } from '../actions'
+import type { ConfigState } from '../types'
+
+vi.mock('@/api/socketActions', () => ({
+ SocketActions: {
+ serverDatabasePostItem: vi.fn(),
+ serverDatabaseDeleteItem: vi.fn(),
+ },
+}))
+
+// Minimal slice of ConfigState needed by the alias mutations/actions.
+const makeState = (aliases: Record = {}): ConfigState =>
+ ({ uiSettings: { dashboard: { aliases } } } as unknown as ConfigState)
+
+// Typed shims so the actions can be called directly with a mock context.
+type Commit = (type: string, payload?: unknown) => void
+type Dispatch = (type: string, payload?: unknown) => Promise
+type UpdateAlias = (ctx: { commit: Commit; dispatch: Dispatch }, payload: { key: string; name: string }) => Promise
+type RemoveAlias = (ctx: { commit: Commit; state: ConfigState }, payload: { key: string }) => Promise
+const updateAlias = actions.updateAlias as unknown as UpdateAlias
+const removeAlias = actions.removeAlias as unknown as RemoveAlias
+
+const KEY = 'output_pin fan2'
+const DB_PATH = ['uiSettings', 'dashboard', 'aliases', 'output_pin fan2']
+
+describe('config store — aliases', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('mutations', () => {
+ it('setAlias adds a new entry (reactively via Vue.set)', () => {
+ const state = makeState()
+ mutations.setAlias(state, { key: KEY, name: 'Side Fan' })
+ expect(state.uiSettings.dashboard.aliases).toStrictEqual({ [KEY]: 'Side Fan' })
+ })
+
+ it('setAlias overwrites an existing entry', () => {
+ const state = makeState({ [KEY]: 'Old' })
+ mutations.setAlias(state, { key: KEY, name: 'New' })
+ expect(state.uiSettings.dashboard.aliases[KEY]).toBe('New')
+ })
+
+ it('setRemoveAlias deletes an entry', () => {
+ const state = makeState({ [KEY]: 'Side Fan' })
+ mutations.setRemoveAlias(state, { key: KEY })
+ expect(state.uiSettings.dashboard.aliases).toStrictEqual({})
+ })
+ })
+
+ describe('actions', () => {
+ it('updateAlias commits and persists the trimmed value (key kept whole)', async () => {
+ const commit = vi.fn()
+ const dispatch = vi.fn()
+ await updateAlias({ commit, dispatch }, { key: KEY, name: ' Side Fan ' })
+ expect(commit).toHaveBeenCalledWith('setAlias', { key: KEY, name: 'Side Fan' })
+ expect(SocketActions.serverDatabasePostItem).toHaveBeenCalledWith(DB_PATH, 'Side Fan')
+ expect(dispatch).not.toHaveBeenCalled()
+ })
+
+ it('updateAlias keeps a dotted key unsplit in the DB path', async () => {
+ const commit = vi.fn()
+ const dispatch = vi.fn()
+ await updateAlias({ commit, dispatch }, { key: 'temperature_sensor my.sensor', name: 'Chamber' })
+ expect(SocketActions.serverDatabasePostItem).toHaveBeenCalledWith(
+ ['uiSettings', 'dashboard', 'aliases', 'temperature_sensor my.sensor'],
+ 'Chamber'
+ )
+ })
+
+ it('updateAlias with a blank name delegates to removeAlias and never persists ""', async () => {
+ const commit = vi.fn()
+ const dispatch = vi.fn()
+ await updateAlias({ commit, dispatch }, { key: KEY, name: ' ' })
+ expect(dispatch).toHaveBeenCalledWith('removeAlias', { key: KEY })
+ expect(commit).not.toHaveBeenCalled()
+ expect(SocketActions.serverDatabasePostItem).not.toHaveBeenCalled()
+ })
+
+ it('removeAlias deletes from the DB when the alias existed', async () => {
+ const commit = vi.fn()
+ const state = makeState({ [KEY]: 'Side Fan' })
+ await removeAlias({ commit, state }, { key: KEY })
+ expect(commit).toHaveBeenCalledWith('setRemoveAlias', { key: KEY })
+ expect(SocketActions.serverDatabaseDeleteItem).toHaveBeenCalledWith(DB_PATH)
+ })
+
+ it('removeAlias skips the network delete when no alias existed', async () => {
+ const commit = vi.fn()
+ const state = makeState({})
+ await removeAlias({ commit, state }, { key: KEY })
+ expect(commit).toHaveBeenCalledWith('setRemoveAlias', { key: KEY })
+ expect(SocketActions.serverDatabaseDeleteItem).not.toHaveBeenCalled()
+ })
+
+ it('removeAlias is not spoofed by a prototype-chain key name', async () => {
+ const commit = vi.fn()
+ const state = makeState({})
+ // `constructor`/`toString` are truthy via `key in obj` but must NOT trigger a delete.
+ await removeAlias({ commit, state }, { key: 'constructor' })
+ await removeAlias({ commit, state }, { key: 'toString' })
+ expect(SocketActions.serverDatabaseDeleteItem).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/src/store/config/__tests__/sensor-colors-guard.spec.ts b/src/store/config/__tests__/sensor-colors-guard.spec.ts
new file mode 100644
index 0000000000..b179f553d6
--- /dev/null
+++ b/src/store/config/__tests__/sensor-colors-guard.spec.ts
@@ -0,0 +1,55 @@
+import { vi } from 'vitest'
+import { SocketActions } from '@/api/socketActions'
+import { actions } from '../actions'
+import type { ConfigState } from '../types'
+
+// Regression for the guarded-delete backport (review finding F5): removeSensorColor
+// must use an own-property check, not `key in`, so a prototype-chain key name
+// cannot spoof a Moonraker delete_item on a never-stored override.
+
+vi.mock('@/api/socketActions', () => ({
+ SocketActions: {
+ serverDatabasePostItem: vi.fn(),
+ serverDatabaseDeleteItem: vi.fn(),
+ },
+}))
+
+const makeState = (sensorColors: Record = {}): ConfigState =>
+ ({ uiSettings: { dashboard: { sensorColors } } } as unknown as ConfigState)
+
+type Commit = (type: string, payload?: unknown) => void
+type RemoveSensorColor = (ctx: { commit: Commit; state: ConfigState }, payload: { key: string }) => Promise
+const removeSensorColor = actions.removeSensorColor as unknown as RemoveSensorColor
+
+const KEY = 'temperature_sensor chamber'
+
+describe('config store — removeSensorColor guarded delete', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('deletes from the DB when the override existed', async () => {
+ const commit = vi.fn()
+ const state = makeState({ [KEY]: '#ff0000' })
+ await removeSensorColor({ commit, state }, { key: KEY })
+ expect(commit).toHaveBeenCalledWith('setRemoveSensorColor', { key: KEY })
+ expect(SocketActions.serverDatabaseDeleteItem).toHaveBeenCalledWith(
+ ['uiSettings', 'dashboard', 'sensorColors', KEY]
+ )
+ })
+
+ it('skips the network delete when no override existed', async () => {
+ const commit = vi.fn()
+ const state = makeState({})
+ await removeSensorColor({ commit, state }, { key: KEY })
+ expect(SocketActions.serverDatabaseDeleteItem).not.toHaveBeenCalled()
+ })
+
+ it('is not spoofed by a prototype-chain key name', async () => {
+ const commit = vi.fn()
+ const state = makeState({})
+ await removeSensorColor({ commit, state }, { key: 'constructor' })
+ await removeSensorColor({ commit, state }, { key: 'toString' })
+ expect(SocketActions.serverDatabaseDeleteItem).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/store/config/actions.ts b/src/store/config/actions.ts
index dc3cf40269..c9be5fb797 100644
--- a/src/store/config/actions.ts
+++ b/src/store/config/actions.ts
@@ -160,14 +160,50 @@ export const actions = {
*/
async removeSensorColor ({ commit, state }, payload: { key: string }) {
// Guard: delete_item errors on a key Moonraker never stored, so only fire the
- // network delete when an override actually existed.
- const existed = payload.key in state.uiSettings.dashboard.sensorColors
+ // network delete when an override actually existed. Own-property check (not
+ // `key in`) so a Klipper section named like a prototype member (e.g.
+ // `constructor`) can't spoof a delete on an empty map — mirrors removeAlias.
+ const existed = Object.prototype.hasOwnProperty.call(state.uiSettings.dashboard.sensorColors, payload.key)
commit('setRemoveSensorColor', payload)
if (existed) {
SocketActions.serverDatabaseDeleteItem(dbKey`uiSettings.dashboard.sensorColors.${payload.key}`)
}
},
+ /**
+ * Set or update the display-name alias for a fan, output pin, LED, heater or sensor.
+ * Display-only: persists to the Moonraker DB `fluidd` namespace, never to Klipper config.
+ */
+ async updateAlias ({ commit, dispatch }, payload: { key: string; name: string }) {
+ const name = payload.name.trim()
+
+ // Never persist an empty alias. Delegate to removeAlias so the DB entry is
+ // cleared via the guarded delete, keeping the getter fallback (`|| default`)
+ // and the tooltip resolver in agreement.
+ if (name === '') {
+ await dispatch('removeAlias', { key: payload.key })
+ return
+ }
+
+ commit('setAlias', { key: payload.key, name })
+ SocketActions.serverDatabasePostItem(dbKey`uiSettings.dashboard.aliases.${payload.key}`, name)
+ },
+
+ /**
+ * Remove the display-name alias for a fan, output pin, LED, heater or sensor.
+ */
+ async removeAlias ({ commit, state }, payload: { key: string }) {
+ // Guard: delete_item errors on a key Moonraker never stored, so only fire the
+ // network delete when an alias actually existed. Use an own-property check
+ // (not `key in`) so a Klipper section named like a prototype member
+ // (e.g. `constructor`, `toString`) can't spoof a delete on an empty map.
+ const existed = Object.prototype.hasOwnProperty.call(state.uiSettings.dashboard.aliases, payload.key)
+ commit('setRemoveAlias', payload)
+ if (existed) {
+ SocketActions.serverDatabaseDeleteItem(dbKey`uiSettings.dashboard.aliases.${payload.key}`)
+ }
+ },
+
async updateFileSystemActiveFilters ({ commit, state }, payload: { root: string, value: FileFilterType[] }) {
commit('setFileSystemActiveFilters', payload)
SocketActions.serverDatabasePostItem(dbKey`uiSettings.fileSystem.activeFilters.${payload.root}`, state.uiSettings.fileSystem.activeFilters[payload.root])
diff --git a/src/store/config/mutations.ts b/src/store/config/mutations.ts
index 2e469fec4c..f03bf76e77 100644
--- a/src/store/config/mutations.ts
+++ b/src/store/config/mutations.ts
@@ -174,6 +174,21 @@ export const mutations = {
Vue.delete(state.uiSettings.dashboard.sensorColors, payload.key)
},
+ /**
+ * Set / update a display-name alias for a fan, output pin, LED, heater or sensor.
+ * Display-only: this never renames the underlying Klipper object.
+ */
+ setAlias (state, payload: { key: string; name: string }) {
+ Vue.set(state.uiSettings.dashboard.aliases, payload.key, payload.name)
+ },
+
+ /**
+ * Remove a display-name alias.
+ */
+ setRemoveAlias (state, payload: { key: string }) {
+ Vue.delete(state.uiSettings.dashboard.aliases, payload.key)
+ },
+
setFileSystemActiveFilters (state, payload: { root: string, value: FileFilterType[] }) {
Vue.set(state.uiSettings.fileSystem.activeFilters, payload.root, payload.value)
},
diff --git a/src/store/config/state.ts b/src/store/config/state.ts
index 0d203c5e45..f98d2f1ab6 100644
--- a/src/store/config/state.ts
+++ b/src/store/config/state.ts
@@ -90,7 +90,8 @@ export const defaultState = (): ConfigState => {
},
dashboard: {
tempPresets: [],
- sensorColors: {}
+ sensorColors: {},
+ aliases: {}
},
tableHeaders: {},
thumbnailSizes: {},
diff --git a/src/store/config/types.ts b/src/store/config/types.ts
index 8d8fb5d654..0e998dbfbc 100644
--- a/src/store/config/types.ts
+++ b/src/store/config/types.ts
@@ -207,6 +207,7 @@ export interface AxisConfig {
export interface DashboardConfig {
tempPresets: TemperaturePreset[];
sensorColors: Record;
+ aliases: Record;
}
export interface SaveByPath {
diff --git a/src/store/printer/__tests__/getters-aliases.spec.ts b/src/store/printer/__tests__/getters-aliases.spec.ts
new file mode 100644
index 0000000000..a47c72f9a0
--- /dev/null
+++ b/src/store/printer/__tests__/getters-aliases.spec.ts
@@ -0,0 +1,128 @@
+import Vue from 'vue'
+import { getters } from '../getters'
+
+// Stub the prettyCase filter + colorset so assertions target the alias-override
+// logic, not prettyCase formatting or colour resolution (covered elsewhere).
+const originalFilters = Vue.$filters
+const originalColorset = Vue.$colorset
+beforeAll(() => {
+ Vue.$filters = { prettyCase: (v: string) => `p:${v}` } as typeof Vue.$filters
+ Vue.$colorset = { next: (_t: string, _k: string, c?: string) => c ?? '#000000' } as unknown as typeof Vue.$colorset
+})
+afterAll(() => {
+ Vue.$filters = originalFilters
+ Vue.$colorset = originalColorset
+})
+
+const gettersArg = {
+ getNonCriticalDisconnectedMcusSet: new Set(),
+ getExtraSensorData: () => ({}),
+} as any
+
+const rootState = (aliases: Record = {}) =>
+ ({ config: { uiSettings: { dashboard: { aliases, sensorColors: {} } } } }) as any
+
+const byKey = (list: any[], key: string) => list.find((o) => o.key === key)
+
+// --- getOutputs (fans / output pins / LEDs) -------------------------------
+
+const outputsState = () => ({
+ printer: {
+ fan: { speed: 0 }, // the part fan (type "fan", name "fan")
+ 'output_pin fan2': { value: 0 },
+ configfile: { settings: {} },
+ },
+}) as any
+
+const outputs = (aliases: Record = {}) =>
+ getters.getOutputs(outputsState(), gettersArg, rootState(aliases))()
+
+describe('printer getters — getOutputs alias override', () => {
+ it('falls back to the computed defaultPrettyName when no alias is set', () => {
+ const row = byKey(outputs(), 'output_pin fan2')
+ expect(row.prettyName).toBe('p:fan2')
+ expect(row.defaultPrettyName).toBe('p:fan2')
+ })
+
+ it('overrides prettyName with the alias, leaving defaultPrettyName + raw identifiers intact', () => {
+ const row = byKey(outputs({ 'output_pin fan2': 'Side Fan' }), 'output_pin fan2')
+ expect(row.prettyName).toBe('Side Fan')
+ expect(row.defaultPrettyName).toBe('p:fan2')
+ // AC4 (display-only): the G-code path reads `name`/`key` — these MUST stay raw.
+ expect(row.name).toBe('fan2')
+ expect(row.key).toBe('output_pin fan2')
+ })
+
+ it('keeps the "Part Fan" special case as the default for the part fan', () => {
+ const row = byKey(outputs(), 'fan')
+ expect(row.prettyName).toBe('Part Fan')
+ expect(row.defaultPrettyName).toBe('Part Fan')
+ })
+
+ it('lets an alias override the "Part Fan" special case', () => {
+ const row = byKey(outputs({ fan: 'My Part Fan' }), 'fan')
+ expect(row.prettyName).toBe('My Part Fan')
+ expect(row.defaultPrettyName).toBe('Part Fan')
+ })
+})
+
+// --- getHeaters (single-token keys: heater_bed / extruder) ------------------
+
+const heatersState = () => ({
+ printer: {
+ heaters: { available_heaters: ['heater_bed', 'extruder'] },
+ heater_bed: { temperature: 20 },
+ extruder: { temperature: 200 },
+ configfile: { settings: {} },
+ },
+}) as any
+
+const heaters = (aliases: Record = {}) =>
+ getters.getHeaters(heatersState(), gettersArg, rootState(aliases))
+
+describe('printer getters — getHeaters alias override', () => {
+ it('resolves single-token heater keys and falls back to the default', () => {
+ const row = byKey(heaters(), 'heater_bed')
+ expect(row.key).toBe('heater_bed')
+ expect(row.prettyName).toBe('p:heater_bed')
+ expect(row.defaultPrettyName).toBe('p:heater_bed')
+ })
+
+ it('overrides a single-token heater with an alias, keeping name/key raw', () => {
+ const row = byKey(heaters({ heater_bed: 'Bed' }), 'heater_bed')
+ expect(row.prettyName).toBe('Bed')
+ expect(row.defaultPrettyName).toBe('p:heater_bed')
+ expect(row.name).toBe('heater_bed')
+ })
+})
+
+// --- getSensors (tmc2240 special-case + temperature_sensor) -----------------
+
+const sensorsState = () => ({
+ printer: {
+ 'temperature_sensor chamber': { temperature: 25 },
+ 'tmc2240 stepper_x': { temperature: 40 },
+ configfile: { settings: {} },
+ },
+}) as any
+
+const sensors = (aliases: Record = {}) =>
+ getters.getSensors(sensorsState(), gettersArg, rootState(aliases))
+
+describe('printer getters — getSensors alias override', () => {
+ it('falls back to the computed default for a temperature sensor', () => {
+ const row = byKey(sensors(), 'temperature_sensor chamber')
+ expect(row.prettyName).toBe('p:chamber')
+ expect(row.defaultPrettyName).toBe('p:chamber')
+ })
+
+ it('overrides the tmc2240 stepper_driver special-case with an alias', () => {
+ const row = byKey(sensors({ 'tmc2240 stepper_x': 'Driver X' }), 'tmc2240 stepper_x')
+ expect(row.prettyName).toBe('Driver X')
+ // defaultPrettyName keeps the (non-obvious) stepper_driver default, whatever it renders to.
+ expect(typeof row.defaultPrettyName).toBe('string')
+ expect(row.defaultPrettyName.length).toBeGreaterThan(0)
+ expect(row.defaultPrettyName).not.toBe('Driver X')
+ expect(row.key).toBe('tmc2240 stepper_x')
+ })
+})
diff --git a/src/store/printer/getters.ts b/src/store/printer/getters.ts
index e54fe68ad9..7b7fd6d3a6 100644
--- a/src/store/printer/getters.ts
+++ b/src/store/printer/getters.ts
@@ -581,6 +581,7 @@ export const getters = {
getHeaters: (state, getters, rootState): Heater[] => {
const nonCriticalDisconnectedMcusSet: Set = getters.getNonCriticalDisconnectedMcusSet
const sensorColors: Record = rootState.config.uiSettings.dashboard.sensorColors
+ const aliases: Record = rootState.config.uiSettings.dashboard.aliases
const heaters: Heater[] = []
@@ -597,7 +598,8 @@ export const getters = {
const name = nameFromSplit || key
const color = resolveSensorColor(sensorColors, key)
- const prettyName = Vue.$filters.prettyCase(name)
+ const defaultPrettyName = Vue.$filters.prettyCase(name)
+ const prettyName = aliases[key] || defaultPrettyName
const disconnected = configHasDisconnectedMcu(config, nonCriticalDisconnectedMcusSet)
@@ -607,6 +609,7 @@ export const getters = {
type,
color,
prettyName,
+ defaultPrettyName,
key,
minTemp: config?.min_temp ?? 0,
maxTemp: config?.max_temp ?? 500,
@@ -662,6 +665,7 @@ export const getters = {
*/
getOutputs: (state, getters, rootState) => (filter?: string[]): Array => {
const sensorColors: Record = rootState.config.uiSettings.dashboard.sensorColors
+ const aliases: Record = rootState.config.uiSettings.dashboard.aliases
// Fans..
const fans = [
@@ -740,9 +744,10 @@ export const getters = {
supportedTypes.includes(type) &&
(!filterByPrefix.includes(type) || !name.startsWith('_'))
) {
- const prettyName = name === 'fan'
+ const defaultPrettyName = name === 'fan'
? 'Part Fan' // If we know its the part fan.
: Vue.$filters.prettyCase(name)
+ const prettyName = aliases[key] || defaultPrettyName
const color = applyColor.includes(type)
? resolveSensorColor(sensorColors, key)
@@ -758,6 +763,7 @@ export const getters = {
config: { ...config },
name,
prettyName,
+ defaultPrettyName,
key,
color,
type,
@@ -804,6 +810,7 @@ export const getters = {
]
const nonCriticalDisconnectedMcusSet: Set = getters.getNonCriticalDisconnectedMcusSet
const sensorColors: Record = rootState.config.uiSettings.dashboard.sensorColors
+ const aliases: Record = rootState.config.uiSettings.dashboard.aliases
const printerKeys = Object.keys(state.printer)
@@ -817,7 +824,7 @@ export const getters = {
const name = nameFromSplit || key
if (!name.startsWith('_')) {
- const prettyName = type === 'tmc2240'
+ const defaultPrettyName = type === 'tmc2240'
? i18n.t('app.general.label.stepper_driver',
{
name:
@@ -826,6 +833,7 @@ export const getters = {
: Vue.$filters.prettyCase(name)
}).toString()
: Vue.$filters.prettyCase(name)
+ const prettyName = aliases[key] || defaultPrettyName
const color = resolveSensorColor(sensorColors, key)
const config = state.printer.configfile.settings[key.toLowerCase()]
@@ -840,6 +848,7 @@ export const getters = {
name,
key,
prettyName,
+ defaultPrettyName,
color,
type,
disconnected
diff --git a/src/store/printer/types.ts b/src/store/printer/types.ts
index 29418b252e..72729541d7 100644
--- a/src/store/printer/types.ts
+++ b/src/store/printer/types.ts
@@ -63,6 +63,9 @@ type OutputType, Partial, Partial {
name: string;
prettyName: string;
+ defaultPrettyName?: string;
key: string;
color?: string;
type: string;
diff --git a/src/util/__tests__/build-output-label.spec.ts b/src/util/__tests__/build-output-label.spec.ts
new file mode 100644
index 0000000000..f91373a23c
--- /dev/null
+++ b/src/util/__tests__/build-output-label.spec.ts
@@ -0,0 +1,20 @@
+import buildOutputLabel from '../build-output-label'
+
+describe('buildOutputLabel', () => {
+ it('escapes the (possibly aliased) display name — AC6', () => {
+ expect(buildOutputLabel('
'))
+ .toBe('<img src=x onerror=alert(1)>')
+ })
+
+ it('passes a plain name through unchanged', () => {
+ expect(buildOutputLabel('Side Fan')).toBe('Side Fan')
+ })
+
+ it('appends the trusted rpm suffix as markup', () => {
+ expect(buildOutputLabel('Side Fan', '1200 rpm')).toBe('Side Fan 1200 rpm')
+ })
+
+ it('escapes the name even when an rpm suffix is present', () => {
+ expect(buildOutputLabel('x', '5 rpm')).toBe('<b>x</b> 5 rpm')
+ })
+})
diff --git a/src/util/__tests__/escape-html.spec.ts b/src/util/__tests__/escape-html.spec.ts
new file mode 100644
index 0000000000..468d5e4bb3
--- /dev/null
+++ b/src/util/__tests__/escape-html.spec.ts
@@ -0,0 +1,30 @@
+import escapeHtml from '../escape-html'
+
+describe('escapeHtml', () => {
+ it.each([
+ ['&', '&'],
+ ['<', '<'],
+ ['>', '>'],
+ ['"', '"'],
+ ["'", '''],
+ ])('escapes %s', (input, expected) => {
+ expect(escapeHtml(input)).toBe(expected)
+ })
+
+ it('escapes & first so entities are not double-encoded', () => {
+ expect(escapeHtml('a & ')).toBe('a & <b>')
+ })
+
+ it('neutralises an HTML/script injection payload', () => {
+ expect(escapeHtml('
'))
+ .toBe('<img src=x onerror=alert(1)>')
+ })
+
+ it('escapes every occurrence, not just the first', () => {
+ expect(escapeHtml('<<>>')).toBe('<<>>')
+ })
+
+ it('leaves plain text untouched', () => {
+ expect(escapeHtml('Side Fan')).toBe('Side Fan')
+ })
+})
diff --git a/src/util/__tests__/output-gcode.spec.ts b/src/util/__tests__/output-gcode.spec.ts
new file mode 100644
index 0000000000..cf4edddfec
--- /dev/null
+++ b/src/util/__tests__/output-gcode.spec.ts
@@ -0,0 +1,34 @@
+import { buildFanSpeedGcode, buildSetPinGcode } from '../output-gcode'
+
+// AC4 (display-only): every emitted command must be keyed by the raw Klipper
+// `name`, never a user-supplied alias / prettyName.
+
+describe('buildFanSpeedGcode', () => {
+ it('emits an absolute M106 for the part fan (no name leaks in)', () => {
+ expect(buildFanSpeedGcode({ type: 'fan', name: 'fan' }, 100)).toBe('M106 S255')
+ })
+
+ it('emits SET_FAN_SPEED keyed by the raw name for a generic fan', () => {
+ const gcode = buildFanSpeedGcode({ type: 'fan_generic', name: 'fan2' }, 50)
+ expect(gcode).toBe('SET_FAN_SPEED FAN=fan2 SPEED=0.5')
+ })
+
+ it('uses the raw name even when a display alias exists on the row', () => {
+ // The builder only ever sees `name`; the alias ("Side Fan") is not an input.
+ const gcode = buildFanSpeedGcode({ type: 'fan_generic', name: 'fan2' }, 100)
+ expect(gcode).toContain('FAN=fan2')
+ expect(gcode).not.toContain('Side Fan')
+ })
+
+ it('returns undefined for a non-controllable fan type', () => {
+ expect(buildFanSpeedGcode({ type: 'temperature_fan', name: 'x' }, 100)).toBeUndefined()
+ })
+})
+
+describe('buildSetPinGcode', () => {
+ it('emits SET_PIN keyed by the raw pin name, never the alias', () => {
+ const gcode = buildSetPinGcode('fan2', 0.5)
+ expect(gcode).toBe('SET_PIN PIN=fan2 VALUE=0.5')
+ expect(gcode).not.toContain('Side Fan')
+ })
+})
diff --git a/src/util/__tests__/resolve-alias-label.spec.ts b/src/util/__tests__/resolve-alias-label.spec.ts
new file mode 100644
index 0000000000..1f37d27438
--- /dev/null
+++ b/src/util/__tests__/resolve-alias-label.spec.ts
@@ -0,0 +1,31 @@
+import resolveAliasLabel from '../resolve-alias-label'
+
+const KEY = 'output_pin fan2'
+
+describe('resolveAliasLabel', () => {
+ it('returns the alias (escaped) when one is set', () => {
+ expect(resolveAliasLabel(KEY, { [KEY]: 'Side Fan' }, 'Default')).toBe('Side Fan')
+ })
+
+ it('falls back to the (escaped) default when no alias is set', () => {
+ expect(resolveAliasLabel(KEY, {}, 'Default')).toBe('Default')
+ })
+
+ it('falls back to the default for an empty-string alias (|| not ??)', () => {
+ // Guards the blank-tooltip class of bug: an empty alias must not blank the label.
+ expect(resolveAliasLabel(KEY, { [KEY]: '' }, 'Default')).toBe('Default')
+ })
+
+ it.each(['#target', '#power', '#speed'])('strips the %s series suffix before lookup', (suffix) => {
+ expect(resolveAliasLabel(`${KEY}${suffix}`, { [KEY]: 'Side Fan' }, 'Default')).toBe('Side Fan')
+ })
+
+ it('HTML-escapes a malicious alias', () => {
+ expect(resolveAliasLabel(KEY, { [KEY]: '
' }, 'Default'))
+ .toBe('<img src=x onerror=alert(1)>')
+ })
+
+ it('HTML-escapes a malicious default label too', () => {
+ expect(resolveAliasLabel(KEY, {}, '