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 @@ + + + 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, {}, '