Skip to content
Open
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
1 change: 1 addition & 0 deletions src/components/layout/AppSettingsNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
112 changes: 112 additions & 0 deletions src/components/settings/AliasSettings.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<template>
<div>
<v-subheader id="aliases">
{{ $t('app.setting.title.aliases') }}
</v-subheader>
<v-card
:elevation="5"
dense
class="mb-4"
>
<app-setting :title="$t('app.setting.label.aliases')">
<template #sub-title>
{{ $t('app.setting.tooltip.aliases') }}
</template>
</app-setting>

<template v-if="items.length">
<v-divider />

<template v-for="(item, index) in items">
<app-setting
:key="item.key"
:title="item.defaultPrettyName"
:sub-title="item.key"
>
<v-text-field
:value="aliases[item.key] || ''"
:placeholder="item.defaultPrettyName"
:aria-label="item.defaultPrettyName"
:maxlength="ALIAS_MAX_LENGTH"
spellcheck="false"
filled
dense
hide-details
clearable
@change="handleChange(item, $event)"
/>
</app-setting>

<v-divider
v-if="index < items.length - 1"
:key="`divider-${item.key}`"
/>
</template>
</template>
</v-card>
</div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import type { Fan, Led, OutputPin, Heater, Sensor } from '@/store/printer/types'

// Shared cap on an alias length (UI-enforced; display-only value).
const ALIAS_MAX_LENGTH = 64

interface AliasItem {
key: string;
// True pre-alias default name (shown as the placeholder). Sourced from the
// getter's `defaultPrettyName`, NOT re-derived, so the fan→'Part Fan' and
// tmc2240 special-cases stay correct even while an alias is active.
defaultPrettyName: string;
}

@Component({})
export default class AliasSettings extends Vue {
readonly ALIAS_MAX_LENGTH = ALIAS_MAX_LENGTH

// Read live from the store so async DB load (mergeWith) and external changes
// stay reflected in every row's text field.
get aliases (): Record<string, string> {
return this.$typedState.config.uiSettings.dashboard.aliases
}

// All aliasable Klipper objects: fans, output pins, LEDs, heaters and sensors.
// Uses the ARRAY getters — getOutputs is curried and must not be spread.
get items (): AliasItem[] {
const fans: Fan[] = this.$typedGetters['printer/getAllFans']
const pins: OutputPin[] = this.$typedGetters['printer/getAllPins']
const leds: Led[] = this.$typedGetters['printer/getAllLeds']
const heaters: Heater[] = this.$typedGetters['printer/getHeaters']
const sensors: Sensor[] = this.$typedGetters['printer/getSensors']

const groups = [...fans, ...pins, ...leds, ...heaters, ...sensors]

// Dedup by full config key (defensive; getter key-groups are disjoint).
const seen = new Set<string>()

return groups
.filter((item) => {
if (!item?.key || seen.has(item.key)) return false
seen.add(item.key)
return true
})
.map((item): AliasItem => ({
key: item.key,
defaultPrettyName: item.defaultPrettyName ?? item.prettyName
}))
.sort((a, b) => a.key.localeCompare(b.key))
}

handleChange (item: AliasItem, value: string | null) {
const name = (value ?? '').trim()

if (name) {
this.$typedDispatch('config/updateAlias', { key: item.key, name })
} else {
this.$typedDispatch('config/removeAlias', { key: item.key })
}
}
}
</script>
24 changes: 14 additions & 10 deletions src/components/widgets/outputs/OutputFan.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
suffix="%"
:value="value"
:reset-value="0"
:label="(rpm) ? `${fan.prettyName} <small>${rpm}</small>` : fan.prettyName"
:label="label"
:rules="[
customRules.minFan
]"
Expand Down Expand Up @@ -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 <small> rpm markup.
get label () {
return buildOutputLabel(this.fan.prettyName, this.rpm)
}

get prettyValue () {
return (this.value === 0)
? this.$t('app.general.label.off')
Expand All @@ -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}`)
}
}

Expand Down
15 changes: 11 additions & 4 deletions src/components/widgets/outputs/OutputPin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<app-named-slider
v-if="pwm"
suffix="%"
:label="pin.prettyName"
:label="label"
:min="0"
:max="100"
:value="value"
Expand All @@ -17,7 +17,7 @@
<app-named-switch
v-else
:disabled="!klippyReady || pin.disconnected"
:label="pin.prettyName"
:label="label"
:value="pin.value > 0"
:loading="hasWait(`${$waits.onSetOutputPin}${pin.name}`)"
@input="handleChange"
Expand All @@ -30,13 +30,19 @@ import { Component, Mixins, Prop } from 'vue-property-decorator'
import StateMixin from '@/mixins/state'
import BrowserMixin from '@/mixins/browser'
import type { OutputPin as IOutputPin } from '@/store/printer/types'
import { encodeGcodeParamValue } from '@/util/gcode-helpers'
import buildOutputLabel from '@/util/build-output-label'
import { buildSetPinGcode } from '@/util/output-gcode'

@Component({})
export default class OutputPin extends Mixins(StateMixin, BrowserMixin) {
@Prop({ type: Object, required: true })
readonly pin!: IOutputPin

// prettyName may be a user-supplied alias rendered as HTML (v-safe-html label).
get label () {
return buildOutputLabel(this.pin.prettyName)
}

get pwm () {
return (
this.pin.pwm ||
Expand Down Expand Up @@ -68,7 +74,8 @@ export default class OutputPin extends Mixins(StateMixin, BrowserMixin) {
target = Math.round(target * this.pin.scale) / 100
}

this.sendGcode(`SET_PIN PIN=${encodeGcodeParamValue(this.pin.name)} VALUE=${target}`, `${this.$waits.onSetOutputPin}${this.pin.name}`)
// Display-only: the command is keyed by the raw Klipper name, never the alias.
this.sendGcode(buildSetPinGcode(this.pin.name, target), `${this.$waits.onSetOutputPin}${this.pin.name}`)
}
}
</script>
55 changes: 13 additions & 42 deletions src/components/widgets/thermals/ThermalChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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({})
Expand Down Expand Up @@ -104,6 +105,10 @@ export default class ThermalChart extends Mixins(BrowserMixin) {
return this.$typedState.config.uiSettings.dashboard.sensorColors
}

get aliases (): Record<string, string> {
return this.$typedState.config.uiSettings.dashboard.aliases
}

@Watch('sensorColors', { deep: true })
onSensorColorsChange () {
if (!this.chart) return
Expand Down Expand Up @@ -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 += `
<div>
${param.marker}
<span style="font-size:${fontSize}px;color:${fontColor};font-weight:400;margin-left:2px">
${this.$filters.prettyCase(name)}:
</span>
<span style="float:right;margin-left:20px;font-size:${fontSize}px;color:${fontColor};font-weight:900">
${param.value[param.seriesName].toFixed(2)}<small>°C</small>`

if (param.value[`${param.seriesName}#target`] != null) {
text += ` / ${param.value[`${param.seriesName}#target`].toFixed()}<small>°C</small>`
}
if (param.value[`${param.seriesName}#power`] != null) {
text += ` / ${(param.value[`${param.seriesName}#power`] * 100).toFixed()}<small>%</small>`
}
if (param.value[`${param.seriesName}#speed`] != null) {
text += ` / ${(param.value[`${param.seriesName}#speed`] * 100).toFixed()}<small>%</small>`
}
text += `</span>
<div style="clear: both"></div>
</div>
<div style="clear: both"></div>`
}
})
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',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { formatThermalTooltip, type ThermalTooltipContext } from '../thermal-tooltip-formatter'

const KEY = 'temperature_sensor chamber'

const ctx = (aliases: Record<string, string> = {}): ThermalTooltipContext => ({
aliases,
prettyCase: (v: string) => `p:${v}`,
fontColor: '#000',
fontSize: 14,
})

const param = (overrides: Record<string, any> = {}) => ({
seriesName: KEY,
marker: '<span></span>',
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]: '<img src=x onerror=alert(1)>' }))
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;')
expect(html).not.toContain('<img src=x')
})

it('renders the temperature value', () => {
const html = formatThermalTooltip([param()], ctx())
expect(html).toContain('42.50')
})
})
Loading