Реализация rule-engine-service: RabbitMQ → правила из PostgreSQL → gRPC

Слушает telemetry.new_reading (ручной ack/nack, реквизишн только при
транспортных ошибках gRPC), кэширует активные automation_rules в памяти
с периодическим обновлением из PostgreSQL (JOIN с devices — резолвит
внутренние ID в external_id, которым оперируют MQTT/Redis/gRPC). Условия
правил (>,<,>=,<=,=,!=) оцениваются обобщённо, без привязки к конкретным
типам устройств. При срабатывании вызывает device-control-service по gRPC
и публикует automation.rule_triggered в RabbitMQ.

Проверено сквозным тестом через docker compose на полном пайплайне:
mosquitto_pub → ingest-service (ClickHouse + telemetry.new_reading) →
rule-engine-service (совпадение правила) → device-control-service (gRPC →
Redis desired_state + MQTT-команда) → automation.rule_triggered. Показание
ниже порога проверено отдельно — правило корректно не срабатывает.
This commit is contained in:
2026-07-26 21:18:59 +05:00
parent 3eb84aae6c
commit abc83faf05
20 changed files with 1334 additions and 10 deletions
@@ -0,0 +1,95 @@
// Package devicecontrolclient wraps the generated DeviceControl gRPC client,
// translating an automation_rules row's generic action_type/action_params
// into the specific TurnOn/TurnOff/SetLevel RPC.
package devicecontrolclient
import (
"context"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
)
type Client struct {
conn *grpc.ClientConn
client devicecontrol.DeviceControlClient
}
func Connect(target string) (*Client, error) {
conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, fmt.Errorf("connect to device-control-service at %s: %w", target, err)
}
return &Client{conn: conn, client: devicecontrol.NewDeviceControlClient(conn)}, nil
}
func (c *Client) Close() error {
return c.conn.Close()
}
// newWithClient builds a Client around an already-constructed gRPC client,
// bypassing Connect. Used by tests to inject a fake devicecontrol.DeviceControlClient.
func newWithClient(client devicecontrol.DeviceControlClient) *Client {
return &Client{client: client}
}
// Result mirrors devicecontrol.CommandResult: Success/Error come from the
// device-control-service's business logic, not a transport-level failure.
type Result struct {
Success bool
Error string
}
// Dispatch issues actionType against deviceID, using params for actions that
// need them (currently just set_level's "level"). The returned error is only
// set for transport/protocol failures; a rejected command comes back as
// Result{Success: false, Error: "..."}.
func (c *Client) Dispatch(ctx context.Context, deviceID, actionType string, params map[string]any) (Result, error) {
switch actionType {
case "turn_on":
res, err := c.client.TurnOn(ctx, &devicecontrol.TurnOnRequest{DeviceId: deviceID})
if err != nil {
return Result{}, fmt.Errorf("turn_on rpc: %w", err)
}
return Result{Success: res.GetSuccess(), Error: res.GetError()}, nil
case "turn_off":
res, err := c.client.TurnOff(ctx, &devicecontrol.TurnOffRequest{DeviceId: deviceID})
if err != nil {
return Result{}, fmt.Errorf("turn_off rpc: %w", err)
}
return Result{Success: res.GetSuccess(), Error: res.GetError()}, nil
case "set_level":
level, ok := numericParam(params, "level")
if !ok {
return Result{}, fmt.Errorf("set_level requires a numeric %q param, got %+v", "level", params)
}
res, err := c.client.SetLevel(ctx, &devicecontrol.SetLevelRequest{DeviceId: deviceID, Level: level})
if err != nil {
return Result{}, fmt.Errorf("set_level rpc: %w", err)
}
return Result{Success: res.GetSuccess(), Error: res.GetError()}, nil
default:
return Result{}, fmt.Errorf("unknown action_type %q", actionType)
}
}
func numericParam(params map[string]any, key string) (float64, bool) {
v, ok := params[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case float64:
return n, true
case int:
return float64(n), true
default:
return 0, false
}
}
@@ -0,0 +1,94 @@
package devicecontrolclient
import (
"context"
"testing"
"google.golang.org/grpc"
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
)
type fakeGRPCClient struct {
lastTurnOn *devicecontrol.TurnOnRequest
lastTurnOff *devicecontrol.TurnOffRequest
lastSetLevel *devicecontrol.SetLevelRequest
result *devicecontrol.CommandResult
err error
}
func (f *fakeGRPCClient) TurnOn(_ context.Context, in *devicecontrol.TurnOnRequest, _ ...grpc.CallOption) (*devicecontrol.CommandResult, error) {
f.lastTurnOn = in
return f.result, f.err
}
func (f *fakeGRPCClient) TurnOff(_ context.Context, in *devicecontrol.TurnOffRequest, _ ...grpc.CallOption) (*devicecontrol.CommandResult, error) {
f.lastTurnOff = in
return f.result, f.err
}
func (f *fakeGRPCClient) SetLevel(_ context.Context, in *devicecontrol.SetLevelRequest, _ ...grpc.CallOption) (*devicecontrol.CommandResult, error) {
f.lastSetLevel = in
return f.result, f.err
}
func TestDispatch_TurnOn(t *testing.T) {
fake := &fakeGRPCClient{result: &devicecontrol.CommandResult{Success: true}}
c := newWithClient(fake)
res, err := c.Dispatch(context.Background(), "fan-1", "turn_on", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Success {
t.Fatalf("got %+v, want success", res)
}
if fake.lastTurnOn == nil || fake.lastTurnOn.DeviceId != "fan-1" {
t.Fatalf("got TurnOn request %+v, want device_id=fan-1", fake.lastTurnOn)
}
}
func TestDispatch_SetLevel(t *testing.T) {
fake := &fakeGRPCClient{result: &devicecontrol.CommandResult{Success: true}}
c := newWithClient(fake)
res, err := c.Dispatch(context.Background(), "light-1", "set_level", map[string]any{"level": 42.5})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Success {
t.Fatalf("got %+v, want success", res)
}
if fake.lastSetLevel == nil || fake.lastSetLevel.Level != 42.5 {
t.Fatalf("got SetLevel request %+v, want level=42.5", fake.lastSetLevel)
}
}
func TestDispatch_SetLevel_MissingParam(t *testing.T) {
c := newWithClient(&fakeGRPCClient{})
if _, err := c.Dispatch(context.Background(), "light-1", "set_level", nil); err == nil {
t.Fatal("expected error for missing level param")
}
}
func TestDispatch_UnknownActionType(t *testing.T) {
c := newWithClient(&fakeGRPCClient{})
if _, err := c.Dispatch(context.Background(), "d1", "dance", nil); err == nil {
t.Fatal("expected error for unknown action_type")
}
}
func TestDispatch_BusinessFailurePassesThrough(t *testing.T) {
fake := &fakeGRPCClient{result: &devicecontrol.CommandResult{Success: false, Error: "device offline"}}
c := newWithClient(fake)
res, err := c.Dispatch(context.Background(), "fan-1", "turn_off", nil)
if err != nil {
t.Fatalf("unexpected transport error: %v", err)
}
if res.Success || res.Error != "device offline" {
t.Fatalf("got %+v, want business failure passed through", res)
}
}