跳到主要内容

HMI 状态机

HMI 中的"状态" = UI 根据数据/用户操作在多个视觉分支之间切换。Darra HMI 通过 CSS class + Alpine 响应式数据 + 轻量 FSM 模式 组合实现, 无需外部库。

四种实现层级 (从简到繁)

  1. CSS class 直接切: 2-3 个状态, 最简
  2. Alpine x-data 状态对象: 中等复杂度, 推荐主力
  3. 轻量 FSM (xstate-like): 多状态 + 转换规则 + 守卫条件
  4. 页面级路由切换: 多页面 HMI (<darra-nav-button>)

1. CSS class 切换 (最简)

.motor                 { background: gray;  border: 2px solid #888; }
.motor.running { background: #22C55E; }
.motor.fault { background: #EF4444; animation: blink .5s infinite; }
.motor.maintenance { background: #F59E0B; }
<div class="motor" :class="{
running: isRunning,
fault: hasFault,
maintenance: inMaintenance
}"
x-data="{ isRunning: false, hasFault: false, inMaintenance: false }"
x-init="
darra.bind('M10.0', v => isRunning = v)
darra.bind('M10.1', v => hasFault = v)
darra.bind('M10.2', v => inMaintenance = v)">
</div>

2. Alpine x-data 状态

<div x-data="motorState()" x-init="bootstrap()">

<h3 x-text="label"></h3>
<div class="icon" :class="cssClass"></div>

<button @click="transitionTo('starting')"
:disabled="!canStart">启动</button>
<button @click="transitionTo('stopping')"
:disabled="!canStop">停止</button>

</div>

<script>
function motorState() {
return {
// ---- 状态 ----
state: 'idle', // idle / starting / running / stopping / fault / maintenance

// ---- 计算属性 (getter) ----
get label() {
return {
idle: '待机', starting: '启动中', running: '运行',
stopping: '停止中', fault: '故障', maintenance: '维护'
}[this.state]
},
get cssClass() {
return 'state-' + this.state
},
get canStart() {
return ['idle', 'maintenance'].includes(this.state)
},
get canStop() {
return ['running', 'starting'].includes(this.state)
},

// ---- 方法 ----
transitionTo(next) {
if (!this.canTransition(this.state, next)) {
console.warn('非法转换:', this.state, '->', next)
return
}
this.state = next
this.syncToPlc()
},
canTransition(from, to) {
const allowed = {
idle: ['starting', 'maintenance'],
starting: ['running', 'stopping', 'fault'],
running: ['stopping', 'fault'],
stopping: ['idle'],
fault: ['idle', 'maintenance'],
maintenance: ['idle']
}
return (allowed[from] || []).includes(to)
},
syncToPlc() {
darra.writeGroup({
'M20.0': this.state === 'starting',
'M20.1': this.state === 'running',
'M20.2': this.state === 'stopping',
'M20.3': this.state === 'maintenance'
})
},
bootstrap() {
// 从 PLC 读回初态
darra.bindGroup(['M10.0','M10.1','M10.2'], (vs) => {
if (vs['M10.1']) this.state = 'fault'
else if (vs['M10.2']) this.state = 'maintenance'
else if (vs['M10.0']) this.state = 'running'
else this.state = 'idle'
})
}
}
}
</script>

3. 轻量 FSM (xstate-like)

对于 5+ 状态 + 多条件转移, 用 FSM 数据结构:

const batchMachine = {
initial: 'idle',
context: { temp: 0, time: 0 },
states: {
idle: {
on: { START: { target: 'charging', cond: (c) => c.temp > 20 } }
},
charging: {
on: { CHARGED: 'heating', FAULT: 'error' }
},
heating: {
on: { REACHED: 'holding', FAULT: 'error' }
},
holding: {
on: { TIMER_DONE: 'cooling', FAULT: 'error' }
},
cooling: {
on: { DONE: 'discharging' }
},
discharging: {
on: { DONE: 'idle' }
},
error: {
on: { RESET: 'idle' }
}
}
}

// ----- 执行器 -----
class SimpleFSM {
constructor(machine) {
this.machine = machine
this.state = machine.initial
this.context = { ...machine.context }
this.listeners = []
}
send(event) {
const node = this.machine.states[this.state]
const transition = node.on && node.on[event]
if (!transition) return false
const target = typeof transition === 'string' ? transition : transition.target
if (transition.cond && !transition.cond(this.context)) return false
const prev = this.state
this.state = target
this.listeners.forEach(cb => cb(prev, this.state, event))
return true
}
onChange(cb) { this.listeners.push(cb) }
}

// ----- 用法 -----
const fsm = new SimpleFSM(batchMachine)

fsm.onChange((from, to, evt) => {
console.log(`${from} -${evt}-> ${to}`)
document.body.className = `batch-${to}`
updatePlcState(to)
})

// PLC 变量驱动事件
darra.bind('M30.0', v => { if (v) fsm.send('START') })
darra.bind('M30.1', v => { if (v) fsm.send('CHARGED') })
darra.bind('M30.2', v => { if (v) fsm.send('REACHED') })
darra.bind('DB1.Temp', v => { fsm.context.temp = v })

4. 设备状态联动

某设备状态变化, 多个 UI 同步响应:

const machine = { state: 'idle', listeners: [] }

machine.setState = function(s) {
const old = this.state
this.state = s
this.listeners.forEach(cb => cb(old, s))
}

// 视觉 1: 按钮 disable
machine.listeners.push((old, now) => {
document.querySelectorAll('.op-btn').forEach(b => b.disabled = (now === 'fault'))
})

// 视觉 2: 顶栏 badge
machine.listeners.push((old, now) => {
document.querySelector('.badge').className = 'badge badge-' + now
})

// 视觉 3: 页面背景
machine.listeners.push((old, now) => {
if (now === 'fault') document.body.classList.add('page-fault')
else document.body.classList.remove('page-fault')
})

// 视觉 4: 切页
machine.listeners.push((old, now) => {
if (now === 'maintenance') location.href = '/local/maintenance'
})

5. 多状态组合 (父子状态)

层级状态 (父状态包含子状态):

// Motor { running { fast, normal, slow }, stopped, fault }

function getCombinedState(parent, child) {
return child ? `${parent}.${child}` : parent
}

const combined = getCombinedState('running', speed > 2000 ? 'fast' : speed > 500 ? 'normal' : 'slow')
document.body.className = 'state-' + combined.replace('.', '-')

CSS 对应:

.state-running-fast   { background: linear-gradient(90deg, #22C55E, #FBBF24); }
.state-running-normal { background: #22C55E; }
.state-running-slow { background: #86EFAC; }

6. 页面级路由切换

多页 HMI 通过 <darra-nav-button route="/reactor">location.href 切页:

// 根据设备状态自动导航
darra.bind('M99.0', v => {
if (v) location.href = '/local/emergency'
})

// 防止误操作的受控跳转
function navigate(route) {
if (currentJob !== null && !confirm('作业未完成, 确认离开?')) return
location.href = '/local' + route
}

7. 完整示例: 焙烧炉状态机

<div x-data="kilnFsm()" x-init="bootstrap()">

<h2>焙烧炉 <span class="badge" :class="`badge-${state}`" x-text="stateLabel"></span></h2>

<div class="progress"
:style="`width: ${progressPercent}%; background: ${progressColor}`">
</div>

<div class="control-panel">
<button @click="dispatch('IGNITE')" :disabled="!can('IGNITE')">点火</button>
<button @click="dispatch('HEAT')" :disabled="!can('HEAT')">升温</button>
<button @click="dispatch('HOLD')" :disabled="!can('HOLD')">保温</button>
<button @click="dispatch('COOL')" :disabled="!can('COOL')">降温</button>
<button @click="dispatch('RESET')" class="danger">复位</button>
</div>

<div class="timer" x-show="state === 'holding'">
剩余: <span x-text="Math.max(0, (holdDuration - (Date.now()-holdStart))/1000).toFixed(0)"></span>
</div>

</div>

<script>
function kilnFsm() {
return {
state: 'idle',
holdStart: 0,
holdDuration: 3600 * 1000,
transitions: {
idle: { IGNITE: 'ignited' },
ignited: { HEAT: 'heating', RESET: 'idle' },
heating: { HOLD: 'holding', RESET: 'idle' },
holding: { COOL: 'cooling', RESET: 'idle' },
cooling: { RESET: 'idle' }
},
get stateLabel() { return { idle:'待机', ignited:'点火', heating:'升温', holding:'保温', cooling:'降温' }[this.state] },
get progressPercent() {
return { idle:0, ignited:20, heating:50, holding:75, cooling:95 }[this.state]
},
get progressColor() {
return { idle:'#888', ignited:'#F59E0B', heating:'#EF4444', holding:'#EF4444', cooling:'#3B82F6' }[this.state]
},
can(evt) { return !!(this.transitions[this.state] || {})[evt] },
dispatch(evt) {
const next = (this.transitions[this.state] || {})[evt]
if (!next) return
this.state = next
if (next === 'holding') this.holdStart = Date.now()
darra.write('DB10.State', next)
},
bootstrap() {
darra.bind('DB10.State', v => { this.state = v })
}
}
}
</script>

陷阱

陷阱解法
状态反复抖动增加最小停留时间 (debounce state change)
从 PLC 读回状态和 UI 本地状态冲突明确"谁是权威", 建议以 PLC 为准
FSM 复杂导致 JSON 难维护提取到 config 文件或引入 XState
UI 不同步所有 setState 走 dispatch, 不要直接赋值 state = ...

相关文档