feat(urgency): 通用紧急度通道(Declarer/Level + 只抬不压折算)+ IoT 首个生产者 + 她自己的结论走结构化标记穿透安静时段
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 13:11:04 +08:00
1 parent 4a2d276881
commit f91a83ff81
14 files changed
+917 -15

No files matched your search

+15 -1
View File
@@ -11,7 +11,11 @@
// Home Assistant)后续通过同一套 Provider 接口接入。
package iot
import "errors"
import (
"errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// DeviceUpdateSubject 是设备状态跃迁事件发布所用的 bus 主题。订阅方使用该主题
// (或 "iot.>")观察设备变化。
@@ -81,4 +85,14 @@ type DeviceUpdate struct {
Params map[string]any `json:"params,omitempty"`
// Message 是跃迁的可读摘要,会呈现到前端的 device_update 信息流中。
Message string `json:"message,omitempty"`
// Severity 是本次跃迁的显式严重度(通用紧急度词汇 urgency.Level 的别名)。
// 零值 SeverityRoutine 表示例行变化,因此既有订阅方与既有的普通设备变化行为
// 逐字不变;命中安全规则(见 safetySeverity)的跃迁才被标成安全域
// (design/12 §6 安全域豁免)。
Severity AlertSeverity `json:"severity"`
}
// Urgency 实现 urgency.Declarer:DeviceUpdate 因此在**通用通道**上声明紧急度,
// 让 backthink(以及任何订阅方)无需认识 IoT 的专有字段就能承认安全域事件。
// 接线点见 backthink 的 triggerForEvent。
func (d DeviceUpdate) Urgency() urgency.Level { return d.Severity }
+10 -2
View File
@@ -147,22 +147,30 @@ func (m *Manager) Status(ctx context.Context, deviceID string) (*Device, error)
return nil, fmt.Errorf("iot: status %q: %w", deviceID, ErrDeviceNotFound)
}
// publishUpdate 把 DeviceUpdate 扇出到 bus。发布失败(例如 ctx 被取消)只记日志、
// publishUpdate 把 DeviceUpdate 扇出到 bus。发布的载荷带上本次跃迁的显式严重度:
// 命中安全规则(safetySeverity)的跃迁被标成安全域,其余一律 SeverityRoutine,
// 因此常规设备变化不会被误升级。发布失败(例如 ctx 被取消)只记日志、
// 不作为错误返回:设备状态已经改变,不应因为某个观察者没收到事件就把本次控制判为
// 失败。
func (m *Manager) publishUpdate(ctx context.Context, provider string, d Device, action string, params map[string]any) {
severity, safety := safetySeverity(d.Type, action)
msg := fmt.Sprintf("设备 %s · %s(%s)", d.Name, action, provider)
if safety {
msg = fmt.Sprintf("%s · 安全域 %s", msg, severity.String())
}
if err := m.bus.Publish(ctx, DeviceUpdateSubject, DeviceUpdate{
Provider: provider,
Device: d,
Action: action,
Params: params,
Message: msg,
Severity: severity,
}); err != nil {
m.log.Error("iot publish device update", "device", d.ID, "action", action, "error", err)
return
}
m.log.Debug("iot device update published", "device", d.ID, "action", action, "provider", provider)
m.log.Debug("iot device update published",
"device", d.ID, "action", action, "provider", provider, "severity", severity.String())
}
// discardLogger 在 log 非 nil 时原样返回,否则返回一个不写任何输出的 logger,
+33
View File
@@ -0,0 +1,33 @@
package iot
// 安全规则表所用的字面量集中在此,避免规则命中里出现魔法字符串。
const (
deviceTypeLock = "lock"
actionUnlock = "unlock"
)
// safetySeverity 报告一次 (设备类型, 动作) 跃迁是否命中已登记的显式安全规则,
// 命中时返回该规则声明的严重度。
//
// 这是 IoT 侧严重度升级的**唯一**起点,规则逐条可审且保守:只有语义上明确属于
// 安全域的动作才升级,未命中的动作一律落在 SeverityRoutine——因此常规设备变化
// 不会因为本机制获得穿透安静时段的资格。
//
// 目前登记的规则(一条):
// - lock/unlock —— 门锁开启。不论开启者是谁,门锁从锁闭变为开启都是一次安全域
// 事件(入侵 / 异常开启),标 critical。
//
// 烟雾、漏水、门窗磁等安全来源的规则属**设备来源**(Provider)自身的语义,待真实
// Provider(MQTT / Home Assistant)接入时在同一张表登记;本机制不预置猜测性的
// 阈值或设备语义。
func safetySeverity(deviceType, action string) (AlertSeverity, bool) {
if deviceType != deviceTypeLock {
return SeverityRoutine, false
}
switch action {
case actionUnlock:
return SeverityCritical, true
default:
return SeverityRoutine, false
}
}
+146
View File
@@ -0,0 +1,146 @@
package iot
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// captureUpdates 订阅 DeviceUpdateSubject 并把收到的载荷送进返回的通道。
func captureUpdates(t *testing.T, m *Manager) <-chan DeviceUpdate {
t.Helper()
ch := make(chan DeviceUpdate, 8)
unsub, err := m.Bus().Subscribe(context.Background(), DeviceUpdateSubject, func(_ context.Context, e bus.Event) error {
if d, ok := e.Payload.(DeviceUpdate); ok {
ch <- d
}
return nil
})
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
t.Cleanup(unsub)
return ch
}
// TestControl_UnlockIsCriticalSafetyEvent 验证门锁开启被标成安全域:
// 这是 IoT 侧唯一的严重度升级出处,也是 quiet hours 豁免的生产端。
func TestControl_UnlockIsCriticalSafetyEvent(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "mock"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
updates := captureUpdates(t, m)
if _, err := m.Control(context.Background(), "lock.front", "unlock", nil); err != nil {
t.Fatalf("Control: %v", err)
}
got := recvIoT(t, updates, 2*time.Second)
if got.Severity != SeverityCritical {
t.Fatalf("Severity = %v, want %v (an unlocked door is a safety event)", got.Severity, SeverityCritical)
}
if !strings.Contains(got.Message, "安全域") {
t.Fatalf("Message = %q, want it to name the safety domain", got.Message)
}
}
// TestControl_RoutineStaysRoutine 是保守默认的反面断言:常规设备变化(以及
// 锁门这类非安全域动作)绝不被升级——安静时段照旧扣下它们。
func TestControl_RoutineStaysRoutine(t *testing.T) {
t.Parallel()
cases := []struct {
name string
device string
action string
}{
{name: "开灯", device: "light.living", action: "on"},
{name: "空调设定温度", device: "ac.living", action: "set_temperature"},
{name: "锁门", device: "lock.front", action: "lock"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "mock"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
updates := captureUpdates(t, m)
params := map[string]any{"temperature": 25}
if _, err := m.Control(context.Background(), tc.device, tc.action, params); err != nil {
t.Fatalf("Control: %v", err)
}
got := recvIoT(t, updates, 2*time.Second)
if got.Severity != SeverityRoutine {
t.Fatalf("Severity = %v, want %v (routine transitions must stay routine)", got.Severity, SeverityRoutine)
}
if strings.Contains(got.Message, "安全域") {
t.Fatalf("Message = %q, want no safety-domain wording", got.Message)
}
})
}
}
// TestAlertSeverity_String 验证严重度标签经通用词汇转发(日志与记录依赖它),
// 且 IoT 侧的类型就是通用紧急度的别名 —— 它没有自己的专有严重度体系。
func TestAlertSeverity_String(t *testing.T) {
t.Parallel()
cases := []AlertSeverity{SeverityRoutine, SeverityWarning, SeverityCritical, AlertSeverity(7)}
for _, in := range cases {
if got, want := in.String(), urgency.Level(int(in)).String(); got != want {
t.Fatalf("AlertSeverity(%d).String() = %q, want the generic label %q", int(in), got, want)
}
}
if got := SeverityCritical.String(); got != "critical" {
t.Fatalf("SeverityCritical.String() = %q, want %q", got, "critical")
}
}
// TestDeviceUpdate_DeclaresUrgencyOnGenericChannel 钉住 IoT 走的是**通用**通道:
// 设备跃迁载荷实现 urgency.Declarer,backthink 因此不必认识 IoT 的专有字段;
// 未标注严重度的跃迁声明零值 routine(默认保守)。
func TestDeviceUpdate_DeclaresUrgencyOnGenericChannel(t *testing.T) {
t.Parallel()
var declared urgency.Declarer = DeviceUpdate{Severity: SeverityCritical}
if got := declared.Urgency(); got != urgency.LevelCritical {
t.Fatalf("DeviceUpdate.Urgency() = %v, want %v", got, urgency.LevelCritical)
}
if got := (DeviceUpdate{}).Urgency(); got != urgency.LevelRoutine {
t.Fatalf("unlabelled DeviceUpdate.Urgency() = %v, want %v", got, urgency.LevelRoutine)
}
}
// TestSafetySeverity_RuleTable 直接钉住规则表:未登记的设备类型与动作一律不升级。
func TestSafetySeverity_RuleTable(t *testing.T) {
t.Parallel()
cases := []struct {
name string
deviceType string
action string
wantSev AlertSeverity
wantHit bool
}{
{name: "门锁开启_命中", deviceType: "lock", action: "unlock", wantSev: SeverityCritical, wantHit: true},
{name: "门锁锁闭_不命中", deviceType: "lock", action: "lock", wantSev: SeverityRoutine, wantHit: false},
{name: "门锁翻转_不命中", deviceType: "lock", action: "toggle", wantSev: SeverityRoutine, wantHit: false},
{name: "灯_不命中", deviceType: "light", action: "on", wantSev: SeverityRoutine, wantHit: false},
{name: "传感器_不命中", deviceType: "sensor", action: "unlock", wantSev: SeverityRoutine, wantHit: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
sev, hit := safetySeverity(tc.deviceType, tc.action)
if hit != tc.wantHit || sev != tc.wantSev {
t.Fatalf("safetySeverity(%q, %q) = (%v, %v), want (%v, %v)",
tc.deviceType, tc.action, sev, hit, tc.wantSev, tc.wantHit)
}
})
}
}
+30
View File
@@ -0,0 +1,30 @@
package iot
import "git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
// AlertSeverity 是 IoT 事件的显式严重度。它是**通用紧急度词汇**
// (internal/urgency)在 IoT 侧的别名 —— IoT 是那条通用通道的**第一个生产者**,
// 而不是拥有一条专用通道:任何别的事件生产者(外围插件、系统 / 能力模块)用同一个
// urgency.Level 声明紧急度,backthink 也一视同仁(design/12 §2 §6 安全域豁免,
// 裁决 1.8 / 07.4.2)。
//
// 零值是 SeverityRoutine,因此既有的设备状态跃迁(开灯、调温、窗帘移动)一律落在
// 例行档:本机制不会让常规设备变化获得穿透安静时段的资格
// (design/12 §6 安全域豁免的反面保证)。
//
// 它只回答「这次跃迁有多严重」,不表达身份、权限或能力边界 —— 身份与权限永远由
// channel 层的 actor 元数据与策略引擎决定。
type AlertSeverity = urgency.Level
// IoT 侧沿用的严重度档位名(与 urgency.Level* 逐一同义),让设备来源与既有调用方
// 继续按设备语义阅读。折算到思考轮重要度的口径见 backthink 的 importanceForUrgency。
const (
// SeverityRoutine 是例行设备变化:折算为 ImportanceNormal,安静时段照常扣下。
SeverityRoutine = urgency.LevelRoutine
// SeverityWarning 是值得注意但非安全域的异常(设备离线、耗材将尽):折算为
// ImportanceHigh,沿袭既有语义通过安静时段,但不穿透工作 / 忙碌。
SeverityWarning = urgency.LevelWarning
// SeverityCritical 是安全域事件(入侵、门锁异常开启、烟雾 / 漏水告警):折算为
// ImportanceCritical,穿透安静时段与工作 / 忙碌。
SeverityCritical = urgency.LevelCritical
)