Compare commits
4
Commits
8c5ebe2967
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abc83faf05 | ||
|
|
3eb84aae6c | ||
|
|
28ca67f42c | ||
|
|
938700cf86 |
@@ -17,6 +17,7 @@ CLICKHOUSE_PASSWORD=change_me
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# --- RabbitMQ (async events between Go services) ---
|
||||
RABBITMQ_HOST=rabbitmq
|
||||
@@ -31,6 +32,20 @@ MQTT_PORT=1883
|
||||
|
||||
# --- device-control-service (gRPC) ---
|
||||
DEVICE_CONTROL_GRPC_PORT=50051
|
||||
DEVICE_CONTROL_MQTT_CLIENT_ID=device-control-service
|
||||
DEVICE_CONTROL_HEALTHCHECK_TIMEOUT=60s
|
||||
DEVICE_CONTROL_HEALTHCHECK_INTERVAL=15s
|
||||
|
||||
# --- ingest-service ---
|
||||
INGEST_MQTT_CLIENT_ID=ingest-service
|
||||
INGEST_MQTT_TOPIC=devices/+/telemetry
|
||||
INGEST_BATCH_MAX_SIZE=500
|
||||
INGEST_BATCH_FLUSH_INTERVAL=5s
|
||||
INGEST_BATCH_FLUSH_TIMEOUT=10s
|
||||
|
||||
# --- rule-engine-service ---
|
||||
DEVICE_CONTROL_HOST=device-control-service
|
||||
RULE_ENGINE_CACHE_REFRESH_INTERVAL=15s
|
||||
|
||||
# --- Laravel app (stage 2) ---
|
||||
APP_KEY=
|
||||
|
||||
@@ -3,6 +3,11 @@ services/**/bin/
|
||||
services/**/*.exe
|
||||
*.test
|
||||
*.out
|
||||
services/ingest-service/ingest-service
|
||||
services/device-control-service/device-control-service
|
||||
services/rule-engine-service/rule-engine-service
|
||||
services/health-check-service/health-check-service
|
||||
services/esp32-emulator/esp32-emulator
|
||||
|
||||
# Laravel
|
||||
laravel-app/vendor/
|
||||
|
||||
+92
-3
@@ -55,6 +55,11 @@ services:
|
||||
- ./migrations/clickhouse:/docker-entrypoint-initdb.d:ro
|
||||
networks:
|
||||
- home-automation
|
||||
healthcheck:
|
||||
test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
@@ -86,6 +91,90 @@ services:
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# App services (ingest-service, device-control-service, rule-engine-service,
|
||||
# health-check-service, esp32-emulator, laravel-app) are added here as they
|
||||
# get implemented — see services/*/README.md for the plan for each one.
|
||||
ingest-service:
|
||||
build: ./services/ingest-service
|
||||
environment:
|
||||
MQTT_HOST: mosquitto
|
||||
MQTT_PORT: 1883
|
||||
CLICKHOUSE_HOST: clickhouse
|
||||
CLICKHOUSE_NATIVE_PORT: 9000
|
||||
CLICKHOUSE_DB: ${CLICKHOUSE_DB}
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USER}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
|
||||
RABBITMQ_HOST: rabbitmq
|
||||
RABBITMQ_PORT: 5672
|
||||
RABBITMQ_USER: ${RABBITMQ_USER}
|
||||
RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
|
||||
depends_on:
|
||||
mosquitto:
|
||||
condition: service_started
|
||||
clickhouse:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- home-automation
|
||||
restart: unless-stopped
|
||||
|
||||
device-control-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/device-control-service/Dockerfile
|
||||
ports:
|
||||
- "${DEVICE_CONTROL_GRPC_PORT}:50051"
|
||||
environment:
|
||||
DEVICE_CONTROL_GRPC_PORT: 50051
|
||||
MQTT_HOST: mosquitto
|
||||
MQTT_PORT: 1883
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
REDIS_DB: ${REDIS_DB}
|
||||
RABBITMQ_HOST: rabbitmq
|
||||
RABBITMQ_PORT: 5672
|
||||
RABBITMQ_USER: ${RABBITMQ_USER}
|
||||
RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
|
||||
DEVICE_CONTROL_HEALTHCHECK_TIMEOUT: ${DEVICE_CONTROL_HEALTHCHECK_TIMEOUT}
|
||||
DEVICE_CONTROL_HEALTHCHECK_INTERVAL: ${DEVICE_CONTROL_HEALTHCHECK_INTERVAL}
|
||||
depends_on:
|
||||
mosquitto:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- home-automation
|
||||
restart: unless-stopped
|
||||
|
||||
rule-engine-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/rule-engine-service/Dockerfile
|
||||
environment:
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DEVICE_CONTROL_HOST: device-control-service
|
||||
DEVICE_CONTROL_GRPC_PORT: 50051
|
||||
RABBITMQ_HOST: rabbitmq
|
||||
RABBITMQ_PORT: 5672
|
||||
RABBITMQ_USER: ${RABBITMQ_USER}
|
||||
RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
|
||||
RULE_ENGINE_CACHE_REFRESH_INTERVAL: ${RULE_ENGINE_CACHE_REFRESH_INTERVAL}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
device-control-service:
|
||||
condition: service_started
|
||||
networks:
|
||||
- home-automation
|
||||
restart: unless-stopped
|
||||
|
||||
# Remaining app services (health-check-service, esp32-emulator,
|
||||
# laravel-app) are added here as they get implemented — see
|
||||
# services/*/README.md for the plan for each one.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS telemetry.telemetry (
|
||||
device_id String,
|
||||
zone_id String,
|
||||
sensor_type String,
|
||||
value Float64,
|
||||
recorded_at DateTime,
|
||||
received_at DateTime
|
||||
) ENGINE = MergeTree()
|
||||
ORDER BY (device_id, recorded_at);
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Core platform schema: users, zones, device types, devices, automation rules.
|
||||
-- Deliberately generic — no growbox-specific tables or columns. A "growbox"
|
||||
-- is just a row in zones with device_types like light/pump/fan attached to it.
|
||||
|
||||
CREATE TABLE users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'owner' CHECK (role IN ('owner', 'viewer')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE zones (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE device_types (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE, -- light, pump, fan, sensor_temp_humidity, vacuum, ...
|
||||
category TEXT NOT NULL CHECK (category IN ('actuator', 'sensor')),
|
||||
capabilities JSONB NOT NULL DEFAULT '[]' -- e.g. ["turn_on","turn_off","set_level"]
|
||||
);
|
||||
|
||||
CREATE TABLE devices (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
zone_id BIGINT NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
|
||||
device_type_id BIGINT NOT NULL REFERENCES device_types(id),
|
||||
name TEXT NOT NULL,
|
||||
external_id TEXT NOT NULL UNIQUE, -- physical device id (ESP32 chip id, etc.)
|
||||
protocol TEXT NOT NULL DEFAULT 'mqtt',
|
||||
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX devices_zone_id_idx ON devices(zone_id);
|
||||
CREATE INDEX devices_external_id_idx ON devices(external_id);
|
||||
|
||||
CREATE TABLE automation_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
zone_id BIGINT NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
|
||||
target_device_id BIGINT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
condition_source_device_id BIGINT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
condition_sensor_type TEXT NOT NULL,
|
||||
condition_operator TEXT NOT NULL CHECK (condition_operator IN ('>', '<', '>=', '<=', '=', '!=')),
|
||||
condition_value DOUBLE PRECISION NOT NULL,
|
||||
action_type TEXT NOT NULL, -- e.g. turn_on, turn_off, set_level
|
||||
action_params JSONB NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX automation_rules_zone_id_idx ON automation_rules(zone_id);
|
||||
CREATE INDEX automation_rules_active_idx ON automation_rules(is_active) WHERE is_active;
|
||||
@@ -0,0 +1,291 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v7.35.1
|
||||
// source: device_control/device_control.proto
|
||||
|
||||
package device_control
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type TurnOnRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *TurnOnRequest) Reset() {
|
||||
*x = TurnOnRequest{}
|
||||
mi := &file_device_control_device_control_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *TurnOnRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*TurnOnRequest) ProtoMessage() {}
|
||||
|
||||
func (x *TurnOnRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_device_control_device_control_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use TurnOnRequest.ProtoReflect.Descriptor instead.
|
||||
func (*TurnOnRequest) Descriptor() ([]byte, []int) {
|
||||
return file_device_control_device_control_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *TurnOnRequest) GetDeviceId() string {
|
||||
if x != nil {
|
||||
return x.DeviceId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type TurnOffRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *TurnOffRequest) Reset() {
|
||||
*x = TurnOffRequest{}
|
||||
mi := &file_device_control_device_control_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *TurnOffRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*TurnOffRequest) ProtoMessage() {}
|
||||
|
||||
func (x *TurnOffRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_device_control_device_control_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use TurnOffRequest.ProtoReflect.Descriptor instead.
|
||||
func (*TurnOffRequest) Descriptor() ([]byte, []int) {
|
||||
return file_device_control_device_control_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *TurnOffRequest) GetDeviceId() string {
|
||||
if x != nil {
|
||||
return x.DeviceId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SetLevelRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||
Level float64 `protobuf:"fixed64,2,opt,name=level,proto3" json:"level,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SetLevelRequest) Reset() {
|
||||
*x = SetLevelRequest{}
|
||||
mi := &file_device_control_device_control_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SetLevelRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SetLevelRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SetLevelRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_device_control_device_control_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SetLevelRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SetLevelRequest) Descriptor() ([]byte, []int) {
|
||||
return file_device_control_device_control_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *SetLevelRequest) GetDeviceId() string {
|
||||
if x != nil {
|
||||
return x.DeviceId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SetLevelRequest) GetLevel() float64 {
|
||||
if x != nil {
|
||||
return x.Level
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type CommandResult struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
|
||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CommandResult) Reset() {
|
||||
*x = CommandResult{}
|
||||
mi := &file_device_control_device_control_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CommandResult) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CommandResult) ProtoMessage() {}
|
||||
|
||||
func (x *CommandResult) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_device_control_device_control_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CommandResult.ProtoReflect.Descriptor instead.
|
||||
func (*CommandResult) Descriptor() ([]byte, []int) {
|
||||
return file_device_control_device_control_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *CommandResult) GetSuccess() bool {
|
||||
if x != nil {
|
||||
return x.Success
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *CommandResult) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_device_control_device_control_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_device_control_device_control_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"#device_control/device_control.proto\x12\x0edevice_control\",\n" +
|
||||
"\rTurnOnRequest\x12\x1b\n" +
|
||||
"\tdevice_id\x18\x01 \x01(\tR\bdeviceId\"-\n" +
|
||||
"\x0eTurnOffRequest\x12\x1b\n" +
|
||||
"\tdevice_id\x18\x01 \x01(\tR\bdeviceId\"D\n" +
|
||||
"\x0fSetLevelRequest\x12\x1b\n" +
|
||||
"\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12\x14\n" +
|
||||
"\x05level\x18\x02 \x01(\x01R\x05level\"?\n" +
|
||||
"\rCommandResult\x12\x18\n" +
|
||||
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" +
|
||||
"\x05error\x18\x02 \x01(\tR\x05error2\xed\x01\n" +
|
||||
"\rDeviceControl\x12F\n" +
|
||||
"\x06TurnOn\x12\x1d.device_control.TurnOnRequest\x1a\x1d.device_control.CommandResult\x12H\n" +
|
||||
"\aTurnOff\x12\x1e.device_control.TurnOffRequest\x1a\x1d.device_control.CommandResult\x12J\n" +
|
||||
"\bSetLevel\x12\x1f.device_control.SetLevelRequest\x1a\x1d.device_control.CommandResultB>Z<git.cactoz.su/cacto/home_automatization/proto/device_controlb\x06proto3"
|
||||
|
||||
var (
|
||||
file_device_control_device_control_proto_rawDescOnce sync.Once
|
||||
file_device_control_device_control_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_device_control_device_control_proto_rawDescGZIP() []byte {
|
||||
file_device_control_device_control_proto_rawDescOnce.Do(func() {
|
||||
file_device_control_device_control_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_device_control_device_control_proto_rawDesc), len(file_device_control_device_control_proto_rawDesc)))
|
||||
})
|
||||
return file_device_control_device_control_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_device_control_device_control_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
|
||||
var file_device_control_device_control_proto_goTypes = []any{
|
||||
(*TurnOnRequest)(nil), // 0: device_control.TurnOnRequest
|
||||
(*TurnOffRequest)(nil), // 1: device_control.TurnOffRequest
|
||||
(*SetLevelRequest)(nil), // 2: device_control.SetLevelRequest
|
||||
(*CommandResult)(nil), // 3: device_control.CommandResult
|
||||
}
|
||||
var file_device_control_device_control_proto_depIdxs = []int32{
|
||||
0, // 0: device_control.DeviceControl.TurnOn:input_type -> device_control.TurnOnRequest
|
||||
1, // 1: device_control.DeviceControl.TurnOff:input_type -> device_control.TurnOffRequest
|
||||
2, // 2: device_control.DeviceControl.SetLevel:input_type -> device_control.SetLevelRequest
|
||||
3, // 3: device_control.DeviceControl.TurnOn:output_type -> device_control.CommandResult
|
||||
3, // 4: device_control.DeviceControl.TurnOff:output_type -> device_control.CommandResult
|
||||
3, // 5: device_control.DeviceControl.SetLevel:output_type -> device_control.CommandResult
|
||||
3, // [3:6] is the sub-list for method output_type
|
||||
0, // [0:3] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_device_control_device_control_proto_init() }
|
||||
func file_device_control_device_control_proto_init() {
|
||||
if File_device_control_device_control_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_device_control_device_control_proto_rawDesc), len(file_device_control_device_control_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 4,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_device_control_device_control_proto_goTypes,
|
||||
DependencyIndexes: file_device_control_device_control_proto_depIdxs,
|
||||
MessageInfos: file_device_control_device_control_proto_msgTypes,
|
||||
}.Build()
|
||||
File_device_control_device_control_proto = out.File
|
||||
file_device_control_device_control_proto_goTypes = nil
|
||||
file_device_control_device_control_proto_depIdxs = nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package device_control;
|
||||
|
||||
option go_package = "git.cactoz.su/cacto/home_automatization/proto/device_control";
|
||||
|
||||
// DeviceControl is the synchronous entry point for issuing commands to a
|
||||
// device. It covers the generic actuator capabilities used across the
|
||||
// platform (device_types.capabilities: turn_on, turn_off, set_level) — no
|
||||
// zone- or device-type-specific methods. A call here updates the device's
|
||||
// desired state and dispatches the command over MQTT; it does not wait for
|
||||
// the device to confirm execution (see the Device Shadow pattern in
|
||||
// device-control-service's README).
|
||||
service DeviceControl {
|
||||
rpc TurnOn(TurnOnRequest) returns (CommandResult);
|
||||
rpc TurnOff(TurnOffRequest) returns (CommandResult);
|
||||
rpc SetLevel(SetLevelRequest) returns (CommandResult);
|
||||
}
|
||||
|
||||
message TurnOnRequest {
|
||||
string device_id = 1;
|
||||
}
|
||||
|
||||
message TurnOffRequest {
|
||||
string device_id = 1;
|
||||
}
|
||||
|
||||
message SetLevelRequest {
|
||||
string device_id = 1;
|
||||
double level = 2;
|
||||
}
|
||||
|
||||
message CommandResult {
|
||||
bool success = 1;
|
||||
string error = 2;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v7.35.1
|
||||
// source: device_control/device_control.proto
|
||||
|
||||
package device_control
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
DeviceControl_TurnOn_FullMethodName = "/device_control.DeviceControl/TurnOn"
|
||||
DeviceControl_TurnOff_FullMethodName = "/device_control.DeviceControl/TurnOff"
|
||||
DeviceControl_SetLevel_FullMethodName = "/device_control.DeviceControl/SetLevel"
|
||||
)
|
||||
|
||||
// DeviceControlClient is the client API for DeviceControl service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// DeviceControl is the synchronous entry point for issuing commands to a
|
||||
// device. It covers the generic actuator capabilities used across the
|
||||
// platform (device_types.capabilities: turn_on, turn_off, set_level) — no
|
||||
// zone- or device-type-specific methods. A call here updates the device's
|
||||
// desired state and dispatches the command over MQTT; it does not wait for
|
||||
// the device to confirm execution (see the Device Shadow pattern in
|
||||
// device-control-service's README).
|
||||
type DeviceControlClient interface {
|
||||
TurnOn(ctx context.Context, in *TurnOnRequest, opts ...grpc.CallOption) (*CommandResult, error)
|
||||
TurnOff(ctx context.Context, in *TurnOffRequest, opts ...grpc.CallOption) (*CommandResult, error)
|
||||
SetLevel(ctx context.Context, in *SetLevelRequest, opts ...grpc.CallOption) (*CommandResult, error)
|
||||
}
|
||||
|
||||
type deviceControlClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewDeviceControlClient(cc grpc.ClientConnInterface) DeviceControlClient {
|
||||
return &deviceControlClient{cc}
|
||||
}
|
||||
|
||||
func (c *deviceControlClient) TurnOn(ctx context.Context, in *TurnOnRequest, opts ...grpc.CallOption) (*CommandResult, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CommandResult)
|
||||
err := c.cc.Invoke(ctx, DeviceControl_TurnOn_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *deviceControlClient) TurnOff(ctx context.Context, in *TurnOffRequest, opts ...grpc.CallOption) (*CommandResult, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CommandResult)
|
||||
err := c.cc.Invoke(ctx, DeviceControl_TurnOff_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *deviceControlClient) SetLevel(ctx context.Context, in *SetLevelRequest, opts ...grpc.CallOption) (*CommandResult, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CommandResult)
|
||||
err := c.cc.Invoke(ctx, DeviceControl_SetLevel_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeviceControlServer is the server API for DeviceControl service.
|
||||
// All implementations must embed UnimplementedDeviceControlServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// DeviceControl is the synchronous entry point for issuing commands to a
|
||||
// device. It covers the generic actuator capabilities used across the
|
||||
// platform (device_types.capabilities: turn_on, turn_off, set_level) — no
|
||||
// zone- or device-type-specific methods. A call here updates the device's
|
||||
// desired state and dispatches the command over MQTT; it does not wait for
|
||||
// the device to confirm execution (see the Device Shadow pattern in
|
||||
// device-control-service's README).
|
||||
type DeviceControlServer interface {
|
||||
TurnOn(context.Context, *TurnOnRequest) (*CommandResult, error)
|
||||
TurnOff(context.Context, *TurnOffRequest) (*CommandResult, error)
|
||||
SetLevel(context.Context, *SetLevelRequest) (*CommandResult, error)
|
||||
mustEmbedUnimplementedDeviceControlServer()
|
||||
}
|
||||
|
||||
// UnimplementedDeviceControlServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedDeviceControlServer struct{}
|
||||
|
||||
func (UnimplementedDeviceControlServer) TurnOn(context.Context, *TurnOnRequest) (*CommandResult, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method TurnOn not implemented")
|
||||
}
|
||||
func (UnimplementedDeviceControlServer) TurnOff(context.Context, *TurnOffRequest) (*CommandResult, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method TurnOff not implemented")
|
||||
}
|
||||
func (UnimplementedDeviceControlServer) SetLevel(context.Context, *SetLevelRequest) (*CommandResult, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetLevel not implemented")
|
||||
}
|
||||
func (UnimplementedDeviceControlServer) mustEmbedUnimplementedDeviceControlServer() {}
|
||||
func (UnimplementedDeviceControlServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeDeviceControlServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to DeviceControlServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeDeviceControlServer interface {
|
||||
mustEmbedUnimplementedDeviceControlServer()
|
||||
}
|
||||
|
||||
func RegisterDeviceControlServer(s grpc.ServiceRegistrar, srv DeviceControlServer) {
|
||||
// If the following call panics, it indicates UnimplementedDeviceControlServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&DeviceControl_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _DeviceControl_TurnOn_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(TurnOnRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DeviceControlServer).TurnOn(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DeviceControl_TurnOn_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DeviceControlServer).TurnOn(ctx, req.(*TurnOnRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DeviceControl_TurnOff_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(TurnOffRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DeviceControlServer).TurnOff(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DeviceControl_TurnOff_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DeviceControlServer).TurnOff(ctx, req.(*TurnOffRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DeviceControl_SetLevel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetLevelRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DeviceControlServer).SetLevel(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DeviceControl_SetLevel_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DeviceControlServer).SetLevel(ctx, req.(*SetLevelRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// DeviceControl_ServiceDesc is the grpc.ServiceDesc for DeviceControl service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var DeviceControl_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "device_control.DeviceControl",
|
||||
HandlerType: (*DeviceControlServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "TurnOn",
|
||||
Handler: _DeviceControl_TurnOn_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "TurnOff",
|
||||
Handler: _DeviceControl_TurnOff_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetLevel",
|
||||
Handler: _DeviceControl_SetLevel_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "device_control/device_control.proto",
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
module git.cactoz.su/cacto/home_automatization/proto
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
google.golang.org/grpc v1.82.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
@@ -0,0 +1,15 @@
|
||||
# Build context is the repo root (not this directory) — device-control-service
|
||||
# depends on the sibling proto/ Go module via a relative `replace` directive.
|
||||
FROM golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY proto/ ./proto/
|
||||
COPY services/device-control-service/ ./services/device-control-service/
|
||||
WORKDIR /src/services/device-control-service
|
||||
RUN go mod download
|
||||
RUN CGO_ENABLED=0 go build -o /out/device-control-service ./cmd/device-control-service
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN adduser -D -u 10001 app
|
||||
COPY --from=build /out/device-control-service /usr/local/bin/device-control-service
|
||||
USER app
|
||||
ENTRYPOINT ["/usr/local/bin/device-control-service"]
|
||||
@@ -1,20 +1,51 @@
|
||||
# device-control-service (Go)
|
||||
|
||||
Статус: пока не реализован.
|
||||
Статус: реализован (MVP).
|
||||
|
||||
Зона ответственности:
|
||||
- Предоставляет gRPC API (см. `proto/device_control.proto`) для отправки
|
||||
команд устройствам — вызывается из Laravel (ручное управление) и из
|
||||
- Предоставляет gRPC API (см. `proto/device_control/device_control.proto`:
|
||||
`TurnOn`, `TurnOff`, `SetLevel` → `CommandResult`) для отправки команд
|
||||
устройствам — вызывается из Laravel (ручное управление) и из
|
||||
rule-engine-service (действия по срабатыванию правил).
|
||||
- Публикует команду в MQTT-топик устройства (`devices/{device_id}/commands`)
|
||||
и слушает подтверждения (`devices/{device_id}/ack`).
|
||||
- Поддерживает паттерн Device Shadow в Redis: `desired_state` (чего хочет
|
||||
пользователь) против `reported_state` (что подтвердило устройство), плюс
|
||||
`status` и `last_seen`.
|
||||
- Владеет циклом health-check (либо делегирует его health-check-service):
|
||||
горутина-тикер переводит устройство в `offline`, когда `last_seen`
|
||||
превышает таймаут, и генерирует событие для notification-service.
|
||||
- Ведёт health-check горутиной-тикером: переводит устройство в `offline`,
|
||||
когда `last_seen` превышает таймаут, и публикует событие в RabbitMQ
|
||||
(`device.status_changed`) для будущего notification-service — как при
|
||||
уходе в офлайн, так и при возврате online.
|
||||
|
||||
Не входит в зону ответственности: решение о том, *когда* отправлять команду
|
||||
на основе показаний датчиков — это логика rule-engine-service. Этот сервис
|
||||
только исполняет команды и отслеживает состояние для той команды, что ему дали.
|
||||
|
||||
## Архитектурные решения
|
||||
|
||||
- **gRPC — не ждёт подтверждения устройства.** `CommandResult.success`
|
||||
означает «desired_state обновлён и команда опубликована в MQTT», а не
|
||||
«устройство подтвердило выполнение». Это соответствует Device Shadow:
|
||||
мгновенный отклик в UI, даже если устройство офлайн или ответит с
|
||||
задержкой. `reported_state` обновляется позже, асинхронно, по приходу ack.
|
||||
- **last_seen обновляется и по телеметрии, и по ack.** Сервис подписан на
|
||||
`devices/+/telemetry` только ради отметки «устройство живо» (сами данные
|
||||
телеметрии парсит и хранит ingest-service) — иначе датчики без исходящих
|
||||
команд (и, соответственно, без ack) никогда не считались бы online.
|
||||
- **Нет зависимости от PostgreSQL.** Список известных устройств — это Redis
|
||||
Set (`devices:known`), который пополняется по мере поступления
|
||||
телеметрии/ack, а не выгружается из таблицы `devices`. Это удерживает
|
||||
сервис в границах MQTT+Redis+RabbitMQ+gRPC, как и в диаграмме архитектуры
|
||||
корневого README.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
cd services/device-control-service
|
||||
go test ./...
|
||||
go build ./cmd/device-control-service
|
||||
```
|
||||
|
||||
Конфигурация — через переменные окружения (секция device-control-service в
|
||||
корневом `.env.example`). `proto/` — отдельный Go-модуль, подключается через
|
||||
`replace` в `go.mod` на относительный путь `../../proto`.
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// Command device-control-service exposes a gRPC API for issuing device
|
||||
// commands, dispatches them over MQTT, maintains the Device Shadow in
|
||||
// Redis, and runs a health-check loop that flips stale devices offline.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/config"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/healthcheck"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/mqttclient"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/rabbitmq"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/server"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/shadow"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
if err := run(logger); err != nil {
|
||||
logger.Error("device-control-service exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(logger *slog.Logger) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
shadowStore, err := shadow.Connect(ctx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer shadowStore.Close()
|
||||
|
||||
publisher, err := rabbitmq.Connect(cfg.RabbitMQURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer publisher.Close()
|
||||
|
||||
mqttClient, err := mqttclient.Connect(mqttclient.Config{
|
||||
BrokerURL: cfg.MQTTBrokerURL,
|
||||
ClientID: cfg.MQTTClientID,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer mqttClient.Close()
|
||||
|
||||
if err := mqttClient.Subscribe("devices/+/telemetry", 1, telemetryHandler(shadowStore, publisher, logger)); err != nil {
|
||||
return fmt.Errorf("subscribe telemetry: %w", err)
|
||||
}
|
||||
if err := mqttClient.Subscribe("devices/+/ack", 1, ackHandler(shadowStore, publisher, logger)); err != nil {
|
||||
return fmt.Errorf("subscribe ack: %w", err)
|
||||
}
|
||||
|
||||
checker := healthcheck.New(shadowStore, cfg.HealthCheckTimeout, cfg.HealthCheckInterval, func(deviceID string) {
|
||||
pubCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := publisher.PublishStatusChanged(pubCtx, deviceID, shadow.StatusOffline); err != nil {
|
||||
logger.Error("publish offline event failed", "device_id", deviceID, "error", err)
|
||||
}
|
||||
}, logger)
|
||||
checker.Start()
|
||||
defer checker.Stop()
|
||||
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.GRPCPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on grpc port %d: %w", cfg.GRPCPort, err)
|
||||
}
|
||||
grpcServer := grpc.NewServer()
|
||||
devicecontrol.RegisterDeviceControlServer(grpcServer, server.New(shadowStore, mqttClient, logger))
|
||||
|
||||
go func() {
|
||||
logger.Info("device-control-service started", "grpc_port", cfg.GRPCPort)
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
logger.Error("grpc server stopped", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
logger.Info("shutting down")
|
||||
grpcServer.GracefulStop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deviceIDFromTopic extracts {device_id} from "devices/{device_id}/<suffix>".
|
||||
func deviceIDFromTopic(topic string) (string, bool) {
|
||||
parts := strings.Split(topic, "/")
|
||||
if len(parts) != 3 || parts[0] != "devices" || parts[1] == "" {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
// telemetryHandler only tracks liveness: ingest-service owns storing and
|
||||
// interpreting telemetry, this service just needs to know the device is alive.
|
||||
func telemetryHandler(store *shadow.Store, publisher *rabbitmq.Publisher, logger *slog.Logger) mqttclient.Handler {
|
||||
return func(topic string, _ []byte, _ time.Time) {
|
||||
deviceID, ok := deviceIDFromTopic(topic)
|
||||
if !ok {
|
||||
logger.Warn("dropping telemetry from unparseable topic", "topic", topic)
|
||||
return
|
||||
}
|
||||
touch(context.Background(), store, publisher, deviceID, logger)
|
||||
}
|
||||
}
|
||||
|
||||
type ackPayload struct {
|
||||
State map[string]any `json:"state"`
|
||||
}
|
||||
|
||||
func ackHandler(store *shadow.Store, publisher *rabbitmq.Publisher, logger *slog.Logger) mqttclient.Handler {
|
||||
return func(topic string, payload []byte, _ time.Time) {
|
||||
deviceID, ok := deviceIDFromTopic(topic)
|
||||
if !ok {
|
||||
logger.Warn("dropping ack from unparseable topic", "topic", topic)
|
||||
return
|
||||
}
|
||||
|
||||
var ack ackPayload
|
||||
if err := json.Unmarshal(payload, &ack); err != nil {
|
||||
logger.Warn("dropping invalid ack payload", "device_id", deviceID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if len(ack.State) > 0 {
|
||||
if err := store.PatchReportedState(ctx, deviceID, ack.State); err != nil {
|
||||
logger.Error("patch reported state failed", "device_id", deviceID, "error", err)
|
||||
}
|
||||
}
|
||||
touch(ctx, store, publisher, deviceID, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func touch(ctx context.Context, store *shadow.Store, publisher *rabbitmq.Publisher, deviceID string, logger *slog.Logger) {
|
||||
becameOnline, err := store.Touch(ctx, deviceID)
|
||||
if err != nil {
|
||||
logger.Error("touch device failed", "device_id", deviceID, "error", err)
|
||||
return
|
||||
}
|
||||
if becameOnline {
|
||||
pubCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := publisher.PublishStatusChanged(pubCtx, deviceID, shadow.StatusOnline); err != nil {
|
||||
logger.Error("publish online event failed", "device_id", deviceID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
module git.cactoz.su/cacto/home_automatization/services/device-control-service
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.cactoz.su/cacto/home_automatization/proto v0.0.0
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/rabbitmq/amqp091-go v1.13.0
|
||||
github.com/redis/go-redis/v9 v9.21.0
|
||||
google.golang.org/grpc v1.82.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
replace git.cactoz.su/cacto/home_automatization/proto => ../../proto
|
||||
@@ -0,0 +1,70 @@
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rabbitmq/amqp091-go v1.13.0 h1:L8NA1WtF76C6KA3LAoufjfLgbist/If1UQYcsOjtxXA=
|
||||
github.com/rabbitmq/amqp091-go v1.13.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
|
||||
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
@@ -0,0 +1,90 @@
|
||||
// Package config loads device-control-service settings from environment
|
||||
// variables, matching the names used in the repo-root .env.example.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
GRPCPort int
|
||||
|
||||
MQTTBrokerURL string
|
||||
MQTTClientID string
|
||||
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
|
||||
RabbitMQURL string
|
||||
|
||||
HealthCheckTimeout time.Duration
|
||||
HealthCheckInterval time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
MQTTBrokerURL: fmt.Sprintf("tcp://%s:%s", getEnv("MQTT_HOST", "localhost"), getEnv("MQTT_PORT", "1883")),
|
||||
MQTTClientID: getEnv("DEVICE_CONTROL_MQTT_CLIENT_ID", "device-control-service"),
|
||||
|
||||
RedisAddr: fmt.Sprintf("%s:%s", getEnv("REDIS_HOST", "localhost"), getEnv("REDIS_PORT", "6379")),
|
||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||
|
||||
RabbitMQURL: fmt.Sprintf("amqp://%s:%s@%s:%s/",
|
||||
getEnv("RABBITMQ_USER", "guest"),
|
||||
getEnv("RABBITMQ_PASSWORD", "guest"),
|
||||
getEnv("RABBITMQ_HOST", "localhost"),
|
||||
getEnv("RABBITMQ_PORT", "5672"),
|
||||
),
|
||||
}
|
||||
|
||||
var err error
|
||||
if cfg.GRPCPort, err = getEnvInt("DEVICE_CONTROL_GRPC_PORT", 50051); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.RedisDB, err = getEnvInt("REDIS_DB", 0); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.HealthCheckTimeout, err = getEnvDuration("DEVICE_CONTROL_HEALTHCHECK_TIMEOUT", 60*time.Second); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.HealthCheckInterval, err = getEnvDuration("DEVICE_CONTROL_HEALTHCHECK_INTERVAL", 15*time.Second); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) (int, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, fallback time.Duration) (time.Duration, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Package healthcheck periodically flips devices to offline when they stop
|
||||
// sending telemetry/acks, per the platform's Device Shadow health-check
|
||||
// design (a goroutine + graceful shutdown, deliberately simple).
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeviceStore is the subset of shadow.Store the checker needs, kept as an
|
||||
// interface so tests don't require a real Redis.
|
||||
type DeviceStore interface {
|
||||
KnownDevices(ctx context.Context) ([]string, error)
|
||||
MarkOfflineIfStale(ctx context.Context, deviceID string, timeout time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
// OnOfflineFunc is called once per device that just transitioned to offline.
|
||||
type OnOfflineFunc func(deviceID string)
|
||||
|
||||
type Checker struct {
|
||||
store DeviceStore
|
||||
timeout time.Duration
|
||||
interval time.Duration
|
||||
onOffline OnOfflineFunc
|
||||
logger *slog.Logger
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func New(store DeviceStore, timeout, interval time.Duration, onOffline OnOfflineFunc, logger *slog.Logger) *Checker {
|
||||
return &Checker{
|
||||
store: store,
|
||||
timeout: timeout,
|
||||
interval: interval,
|
||||
onOffline: onOffline,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the periodic scan loop. Call once.
|
||||
func (c *Checker) Start() {
|
||||
c.stop = make(chan struct{})
|
||||
c.done = make(chan struct{})
|
||||
go c.loop()
|
||||
}
|
||||
|
||||
// Stop signals the loop to exit and waits for it to finish.
|
||||
func (c *Checker) Stop() {
|
||||
close(c.stop)
|
||||
<-c.done
|
||||
}
|
||||
|
||||
func (c *Checker) loop() {
|
||||
defer close(c.done)
|
||||
ticker := time.NewTicker(c.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.checkAll()
|
||||
case <-c.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Checker) checkAll() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.interval)
|
||||
defer cancel()
|
||||
|
||||
ids, err := c.store.KnownDevices(ctx)
|
||||
if err != nil {
|
||||
c.logger.Error("healthcheck: list known devices failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
changed, err := c.store.MarkOfflineIfStale(ctx, id, c.timeout)
|
||||
if err != nil {
|
||||
c.logger.Error("healthcheck: check device failed", "device_id", id, "error", err)
|
||||
continue
|
||||
}
|
||||
if changed {
|
||||
c.logger.Info("device marked offline", "device_id", id)
|
||||
if c.onOffline != nil {
|
||||
c.onOffline(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
known []string
|
||||
stale map[string]bool // deviceID -> whether MarkOfflineIfStale should report a change
|
||||
changes []string // devices actually marked changed, in call order
|
||||
}
|
||||
|
||||
func (f *fakeStore) KnownDevices(context.Context) ([]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.known...), nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) MarkOfflineIfStale(_ context.Context, deviceID string, _ time.Duration) (bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if !f.stale[deviceID] {
|
||||
return false, nil
|
||||
}
|
||||
// Only report the transition once, like the real store would.
|
||||
f.stale[deviceID] = false
|
||||
f.changes = append(f.changes, deviceID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestChecker_FlipsStaleDevicesAndCallsOnOffline(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
known: []string{"d1", "d2"},
|
||||
stale: map[string]bool{"d1": true, "d2": false},
|
||||
}
|
||||
|
||||
offline := make(chan string, 2)
|
||||
c := New(store, time.Minute, 10*time.Millisecond, func(deviceID string) {
|
||||
offline <- deviceID
|
||||
}, discardLogger())
|
||||
c.Start()
|
||||
defer c.Stop()
|
||||
|
||||
select {
|
||||
case id := <-offline:
|
||||
if id != "d1" {
|
||||
t.Fatalf("got offline callback for %q, want d1", id)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for onOffline callback")
|
||||
}
|
||||
|
||||
select {
|
||||
case id := <-offline:
|
||||
t.Fatalf("unexpected second onOffline callback for %q (d2 was never stale)", id)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// expected: no further callbacks
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecker_StopWaitsForLoopExit(t *testing.T) {
|
||||
store := &fakeStore{known: nil}
|
||||
c := New(store, time.Minute, time.Hour, nil, discardLogger())
|
||||
c.Start()
|
||||
c.Stop() // must return without hanging
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package mqttclient wraps paho for device-control-service's two-way MQTT
|
||||
// use: subscribing to telemetry/ack topics for liveness and reported state,
|
||||
// and publishing commands to devices.
|
||||
package mqttclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// Handler receives one message's topic, raw payload, and arrival time.
|
||||
type Handler func(topic string, payload []byte, receivedAt time.Time)
|
||||
|
||||
type Config struct {
|
||||
BrokerURL string // e.g. tcp://mosquitto:1883
|
||||
ClientID string
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
topic string
|
||||
qos byte
|
||||
handler Handler
|
||||
}
|
||||
|
||||
// Client wraps a paho client, replaying every registered subscription on
|
||||
// each (re)connect so a dropped connection resubscribes automatically.
|
||||
type Client struct {
|
||||
client mqtt.Client
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
subs []subscription
|
||||
}
|
||||
|
||||
func Connect(cfg Config, logger *slog.Logger) (*Client, error) {
|
||||
c := &Client{logger: logger}
|
||||
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(cfg.BrokerURL).
|
||||
SetClientID(cfg.ClientID).
|
||||
SetAutoReconnect(true).
|
||||
SetConnectRetry(true).
|
||||
SetOnConnectHandler(func(mc mqtt.Client) {
|
||||
c.resubscribeAll(mc)
|
||||
}).
|
||||
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
|
||||
logger.Warn("mqtt connection lost", "error", err)
|
||||
})
|
||||
|
||||
mqttClient := mqtt.NewClient(opts)
|
||||
token := mqttClient.Connect()
|
||||
token.Wait()
|
||||
if err := token.Error(); err != nil {
|
||||
return nil, fmt.Errorf("connect to mqtt broker %s: %w", cfg.BrokerURL, err)
|
||||
}
|
||||
c.client = mqttClient
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Subscribe registers a handler for topic. It subscribes immediately if
|
||||
// already connected, and will be replayed on every future reconnect.
|
||||
func (c *Client) Subscribe(topic string, qos byte, handler Handler) error {
|
||||
c.mu.Lock()
|
||||
c.subs = append(c.subs, subscription{topic: topic, qos: qos, handler: handler})
|
||||
c.mu.Unlock()
|
||||
|
||||
if !c.client.IsConnectionOpen() {
|
||||
return nil
|
||||
}
|
||||
return c.subscribeOne(c.client, subscription{topic: topic, qos: qos, handler: handler})
|
||||
}
|
||||
|
||||
func (c *Client) resubscribeAll(mc mqtt.Client) {
|
||||
c.mu.Lock()
|
||||
subs := append([]subscription(nil), c.subs...)
|
||||
c.mu.Unlock()
|
||||
|
||||
for _, s := range subs {
|
||||
if err := c.subscribeOne(mc, s); err != nil {
|
||||
c.logger.Error("mqtt subscribe failed", "topic", s.topic, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) subscribeOne(mc mqtt.Client, s subscription) error {
|
||||
token := mc.Subscribe(s.topic, s.qos, func(_ mqtt.Client, msg mqtt.Message) {
|
||||
s.handler(msg.Topic(), msg.Payload(), time.Now())
|
||||
})
|
||||
token.Wait()
|
||||
return token.Error()
|
||||
}
|
||||
|
||||
// Publish sends payload to topic and waits for the publish to complete.
|
||||
func (c *Client) Publish(topic string, qos byte, retained bool, payload []byte) error {
|
||||
token := c.client.Publish(topic, qos, retained, payload)
|
||||
token.Wait()
|
||||
return token.Error()
|
||||
}
|
||||
|
||||
// Close disconnects, waiting up to 250ms for in-flight work to settle.
|
||||
func (c *Client) Close() {
|
||||
c.client.Disconnect(250)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Package rabbitmq publishes device online/offline transitions for
|
||||
// notification-service to consume. Delivery reliability matters more than
|
||||
// latency here, so messages are persistent and the queue is durable.
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const StatusChangedQueue = "device.status_changed"
|
||||
|
||||
type Publisher struct {
|
||||
conn *amqp.Connection
|
||||
ch *amqp.Channel
|
||||
}
|
||||
|
||||
func Connect(url string) (*Publisher, error) {
|
||||
conn, err := amqp.Dial(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial rabbitmq: %w", err)
|
||||
}
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("open channel: %w", err)
|
||||
}
|
||||
|
||||
if _, err := ch.QueueDeclare(StatusChangedQueue, true, false, false, false, nil); err != nil {
|
||||
ch.Close()
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("declare queue %s: %w", StatusChangedQueue, err)
|
||||
}
|
||||
|
||||
return &Publisher{conn: conn, ch: ch}, nil
|
||||
}
|
||||
|
||||
func (p *Publisher) Close() error {
|
||||
if err := p.ch.Close(); err != nil {
|
||||
p.conn.Close()
|
||||
return err
|
||||
}
|
||||
return p.conn.Close()
|
||||
}
|
||||
|
||||
type statusChangedEvent struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Status string `json:"status"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// PublishStatusChanged emits one event when a device transitions online or offline.
|
||||
func (p *Publisher) PublishStatusChanged(ctx context.Context, deviceID, status string) error {
|
||||
body, err := json.Marshal(statusChangedEvent{
|
||||
DeviceID: deviceID,
|
||||
Status: status,
|
||||
At: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event: %w", err)
|
||||
}
|
||||
|
||||
err = p.ch.PublishWithContext(ctx, "", StatusChangedQueue, false, false, amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish status change for device %s: %w", deviceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package server implements the DeviceControl gRPC service: it patches the
|
||||
// device's desired state in Redis and dispatches the command over MQTT.
|
||||
// It deliberately does not wait for the device to execute the command —
|
||||
// see the Device Shadow note in the service README.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
|
||||
)
|
||||
|
||||
// ShadowPatcher is the subset of shadow.Store the server needs.
|
||||
type ShadowPatcher interface {
|
||||
PatchDesiredState(ctx context.Context, deviceID string, patch map[string]any) error
|
||||
}
|
||||
|
||||
// CommandPublisher is the subset of mqttclient.Client the server needs.
|
||||
type CommandPublisher interface {
|
||||
Publish(topic string, qos byte, retained bool, payload []byte) error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
devicecontrol.UnimplementedDeviceControlServer
|
||||
|
||||
shadow ShadowPatcher
|
||||
mqtt CommandPublisher
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func New(shadow ShadowPatcher, mqtt CommandPublisher, logger *slog.Logger) *Server {
|
||||
return &Server{shadow: shadow, mqtt: mqtt, logger: logger}
|
||||
}
|
||||
|
||||
func (s *Server) TurnOn(ctx context.Context, req *devicecontrol.TurnOnRequest) (*devicecontrol.CommandResult, error) {
|
||||
return s.dispatch(ctx, req.GetDeviceId(),
|
||||
map[string]any{"power": "on"},
|
||||
map[string]any{"action": "turn_on"},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) TurnOff(ctx context.Context, req *devicecontrol.TurnOffRequest) (*devicecontrol.CommandResult, error) {
|
||||
return s.dispatch(ctx, req.GetDeviceId(),
|
||||
map[string]any{"power": "off"},
|
||||
map[string]any{"action": "turn_off"},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) SetLevel(ctx context.Context, req *devicecontrol.SetLevelRequest) (*devicecontrol.CommandResult, error) {
|
||||
return s.dispatch(ctx, req.GetDeviceId(),
|
||||
map[string]any{"level": req.GetLevel()},
|
||||
map[string]any{"action": "set_level", "level": req.GetLevel()},
|
||||
)
|
||||
}
|
||||
|
||||
// dispatch is shared by all three RPCs: patch desired state first (so the UI
|
||||
// gets an instant, optimistic view even if the device is offline), then hand
|
||||
// the command to MQTT. Business failures come back as CommandResult.Error,
|
||||
// not a gRPC error — the wire contract is success/error in one message.
|
||||
func (s *Server) dispatch(ctx context.Context, deviceID string, desiredPatch, commandPayload map[string]any) (*devicecontrol.CommandResult, error) {
|
||||
if deviceID == "" {
|
||||
return &devicecontrol.CommandResult{Success: false, Error: "device_id is required"}, nil
|
||||
}
|
||||
|
||||
if err := s.shadow.PatchDesiredState(ctx, deviceID, desiredPatch); err != nil {
|
||||
s.logger.Error("patch desired state failed", "device_id", deviceID, "error", err)
|
||||
return &devicecontrol.CommandResult{Success: false, Error: "failed to update desired state"}, nil
|
||||
}
|
||||
|
||||
body, err := json.Marshal(commandPayload)
|
||||
if err != nil {
|
||||
s.logger.Error("encode command failed", "device_id", deviceID, "error", err)
|
||||
return &devicecontrol.CommandResult{Success: false, Error: "failed to encode command"}, nil
|
||||
}
|
||||
|
||||
topic := fmt.Sprintf("devices/%s/commands", deviceID)
|
||||
if err := s.mqtt.Publish(topic, 1, false, body); err != nil {
|
||||
s.logger.Error("publish command failed", "device_id", deviceID, "topic", topic, "error", err)
|
||||
return &devicecontrol.CommandResult{Success: false, Error: "failed to dispatch command"}, nil
|
||||
}
|
||||
|
||||
return &devicecontrol.CommandResult{Success: true}, nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
|
||||
)
|
||||
|
||||
type fakeShadow struct {
|
||||
err error
|
||||
patches []map[string]any
|
||||
}
|
||||
|
||||
func (f *fakeShadow) PatchDesiredState(_ context.Context, _ string, patch map[string]any) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.patches = append(f.patches, patch)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeMQTT struct {
|
||||
err error
|
||||
topic string
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func (f *fakeMQTT) Publish(topic string, _ byte, _ bool, payload []byte) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.topic = topic
|
||||
f.payload = payload
|
||||
return nil
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestTurnOn_Success(t *testing.T) {
|
||||
sh := &fakeShadow{}
|
||||
mq := &fakeMQTT{}
|
||||
s := New(sh, mq, discardLogger())
|
||||
|
||||
res, err := s.TurnOn(context.Background(), &devicecontrol.TurnOnRequest{DeviceId: "d1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !res.Success {
|
||||
t.Fatalf("expected success, got error %q", res.Error)
|
||||
}
|
||||
if len(sh.patches) != 1 || sh.patches[0]["power"] != "on" {
|
||||
t.Fatalf("got desired patches %+v, want [{power: on}]", sh.patches)
|
||||
}
|
||||
if mq.topic != "devices/d1/commands" {
|
||||
t.Fatalf("got topic %q, want devices/d1/commands", mq.topic)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(mq.payload, &payload); err != nil {
|
||||
t.Fatalf("unmarshal published payload: %v", err)
|
||||
}
|
||||
if payload["action"] != "turn_on" {
|
||||
t.Fatalf("got payload %+v, want action=turn_on", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLevel_Success(t *testing.T) {
|
||||
sh := &fakeShadow{}
|
||||
mq := &fakeMQTT{}
|
||||
s := New(sh, mq, discardLogger())
|
||||
|
||||
res, err := s.SetLevel(context.Background(), &devicecontrol.SetLevelRequest{DeviceId: "d1", Level: 42.5})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !res.Success {
|
||||
t.Fatalf("expected success, got error %q", res.Error)
|
||||
}
|
||||
if sh.patches[0]["level"] != 42.5 {
|
||||
t.Fatalf("got desired patch %+v, want level=42.5", sh.patches[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_MissingDeviceID(t *testing.T) {
|
||||
s := New(&fakeShadow{}, &fakeMQTT{}, discardLogger())
|
||||
|
||||
res, err := s.TurnOff(context.Background(), &devicecontrol.TurnOffRequest{DeviceId: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected transport error: %v", err)
|
||||
}
|
||||
if res.Success {
|
||||
t.Fatal("expected failure for missing device_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_ShadowFailure(t *testing.T) {
|
||||
s := New(&fakeShadow{err: errors.New("redis down")}, &fakeMQTT{}, discardLogger())
|
||||
|
||||
res, err := s.TurnOn(context.Background(), &devicecontrol.TurnOnRequest{DeviceId: "d1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected transport error: %v", err)
|
||||
}
|
||||
if res.Success {
|
||||
t.Fatal("expected failure when shadow patch errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_PublishFailure(t *testing.T) {
|
||||
s := New(&fakeShadow{}, &fakeMQTT{err: errors.New("broker unreachable")}, discardLogger())
|
||||
|
||||
res, err := s.TurnOn(context.Background(), &devicecontrol.TurnOnRequest{DeviceId: "d1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected transport error: %v", err)
|
||||
}
|
||||
if res.Success {
|
||||
t.Fatal("expected failure when mqtt publish errors")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Package shadow implements the Device Shadow pattern on top of Redis:
|
||||
// desired_state (what the user wants) vs reported_state (what the device
|
||||
// confirmed), plus status and last_seen for liveness tracking.
|
||||
package shadow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// knownDevicesKey backs a lightweight device registry: health-check needs to
|
||||
// know which device IDs to scan, and this service has no PostgreSQL access
|
||||
// (device registration lives in Laravel), so every device we hear from gets
|
||||
// added to this set.
|
||||
const knownDevicesKey = "devices:known"
|
||||
|
||||
func desiredKey(deviceID string) string { return "device:" + deviceID + ":desired_state" }
|
||||
func reportedKey(deviceID string) string { return "device:" + deviceID + ":reported_state" }
|
||||
func statusKey(deviceID string) string { return "device:" + deviceID + ":status" }
|
||||
func lastSeenKey(deviceID string) string { return "device:" + deviceID + ":last_seen" }
|
||||
|
||||
const (
|
||||
StatusOnline = "online"
|
||||
StatusOffline = "offline"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func New(rdb *redis.Client) *Store {
|
||||
return &Store{rdb: rdb}
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, addr, password string, db int) (*Store, error) {
|
||||
rdb := redis.NewClient(&redis.Options{Addr: addr, Password: password, DB: db})
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
return nil, fmt.Errorf("ping redis: %w", err)
|
||||
}
|
||||
return New(rdb), nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.rdb.Close()
|
||||
}
|
||||
|
||||
// PatchDesiredState merges patch into the device's desired_state JSON object.
|
||||
func (s *Store) PatchDesiredState(ctx context.Context, deviceID string, patch map[string]any) error {
|
||||
return s.patchJSON(ctx, desiredKey(deviceID), patch)
|
||||
}
|
||||
|
||||
// PatchReportedState merges patch into the device's reported_state JSON object.
|
||||
func (s *Store) PatchReportedState(ctx context.Context, deviceID string, patch map[string]any) error {
|
||||
return s.patchJSON(ctx, reportedKey(deviceID), patch)
|
||||
}
|
||||
|
||||
func (s *Store) patchJSON(ctx context.Context, key string, patch map[string]any) error {
|
||||
existing := map[string]any{}
|
||||
raw, err := s.rdb.Get(ctx, key).Result()
|
||||
switch {
|
||||
case err == redis.Nil:
|
||||
// no existing state yet, start from an empty object
|
||||
case err != nil:
|
||||
return fmt.Errorf("get %s: %w", key, err)
|
||||
default:
|
||||
if err := json.Unmarshal([]byte(raw), &existing); err != nil {
|
||||
return fmt.Errorf("unmarshal existing %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range patch {
|
||||
existing[k] = v
|
||||
}
|
||||
|
||||
merged, err := json.Marshal(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s: %w", key, err)
|
||||
}
|
||||
if err := s.rdb.Set(ctx, key, merged, 0).Err(); err != nil {
|
||||
return fmt.Errorf("set %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Touch registers deviceID as known, bumps last_seen to now, and — if the
|
||||
// device wasn't already marked online — flips status to online. It reports
|
||||
// whether that online transition happened, so the caller can decide whether
|
||||
// a "device back online" event is worth emitting.
|
||||
func (s *Store) Touch(ctx context.Context, deviceID string) (becameOnline bool, err error) {
|
||||
if err := s.rdb.SAdd(ctx, knownDevicesKey, deviceID).Err(); err != nil {
|
||||
return false, fmt.Errorf("register known device %s: %w", deviceID, err)
|
||||
}
|
||||
if err := s.rdb.Set(ctx, lastSeenKey(deviceID), time.Now().Unix(), 0).Err(); err != nil {
|
||||
return false, fmt.Errorf("set last_seen for %s: %w", deviceID, err)
|
||||
}
|
||||
|
||||
status, err := s.rdb.Get(ctx, statusKey(deviceID)).Result()
|
||||
if err != nil && err != redis.Nil {
|
||||
return false, fmt.Errorf("get status for %s: %w", deviceID, err)
|
||||
}
|
||||
if status == StatusOnline {
|
||||
return false, nil
|
||||
}
|
||||
if err := s.rdb.Set(ctx, statusKey(deviceID), StatusOnline, 0).Err(); err != nil {
|
||||
return false, fmt.Errorf("set status online for %s: %w", deviceID, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// MarkOfflineIfStale flips deviceID to offline if it is currently online and
|
||||
// its last_seen is older than timeout. It reports whether a change was made.
|
||||
func (s *Store) MarkOfflineIfStale(ctx context.Context, deviceID string, timeout time.Duration) (changed bool, err error) {
|
||||
status, err := s.rdb.Get(ctx, statusKey(deviceID)).Result()
|
||||
if err == redis.Nil || status != StatusOnline {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get status for %s: %w", deviceID, err)
|
||||
}
|
||||
|
||||
lastSeenRaw, err := s.rdb.Get(ctx, lastSeenKey(deviceID)).Result()
|
||||
if err != nil && err != redis.Nil {
|
||||
return false, fmt.Errorf("get last_seen for %s: %w", deviceID, err)
|
||||
}
|
||||
|
||||
var lastSeen time.Time
|
||||
if lastSeenRaw != "" {
|
||||
sec, err := strconv.ParseInt(lastSeenRaw, 10, 64)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse last_seen for %s: %w", deviceID, err)
|
||||
}
|
||||
lastSeen = time.Unix(sec, 0)
|
||||
}
|
||||
|
||||
if time.Since(lastSeen) <= timeout {
|
||||
return false, nil
|
||||
}
|
||||
if err := s.rdb.Set(ctx, statusKey(deviceID), StatusOffline, 0).Err(); err != nil {
|
||||
return false, fmt.Errorf("set status offline for %s: %w", deviceID, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// KnownDevices lists every device ID this instance has ever heard from.
|
||||
func (s *Store) KnownDevices(ctx context.Context) ([]string, error) {
|
||||
ids, err := s.rdb.SMembers(ctx, knownDevicesKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list known devices: %w", err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package shadow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { rdb.Close() })
|
||||
return New(rdb)
|
||||
}
|
||||
|
||||
func TestPatchDesiredState_MergesFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.PatchDesiredState(ctx, "d1", map[string]any{"power": "on"}); err != nil {
|
||||
t.Fatalf("first patch: %v", err)
|
||||
}
|
||||
if err := s.PatchDesiredState(ctx, "d1", map[string]any{"level": 42.0}); err != nil {
|
||||
t.Fatalf("second patch: %v", err)
|
||||
}
|
||||
|
||||
raw, err := s.rdb.Get(ctx, desiredKey("d1")).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got["power"] != "on" || got["level"] != 42.0 {
|
||||
t.Fatalf("got %+v, want power=on and level=42", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouch_FirstCallReportsOnlineTransition(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
becameOnline, err := s.Touch(ctx, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("touch: %v", err)
|
||||
}
|
||||
if !becameOnline {
|
||||
t.Fatal("expected first touch to report an online transition")
|
||||
}
|
||||
|
||||
becameOnline, err = s.Touch(ctx, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("second touch: %v", err)
|
||||
}
|
||||
if becameOnline {
|
||||
t.Fatal("expected second touch (already online) to report no transition")
|
||||
}
|
||||
|
||||
ids, err := s.KnownDevices(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("known devices: %v", err)
|
||||
}
|
||||
if len(ids) != 1 || ids[0] != "d1" {
|
||||
t.Fatalf("got known devices %+v, want [d1]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkOfflineIfStale(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if _, err := s.Touch(ctx, "d1"); err != nil {
|
||||
t.Fatalf("touch: %v", err)
|
||||
}
|
||||
|
||||
changed, err := s.MarkOfflineIfStale(ctx, "d1", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("mark offline (not stale): %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Fatal("device just touched should not be considered stale")
|
||||
}
|
||||
|
||||
changed, err = s.MarkOfflineIfStale(ctx, "d1", -time.Second) // any age counts as stale
|
||||
if err != nil {
|
||||
t.Fatalf("mark offline (stale): %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("expected status to flip to offline")
|
||||
}
|
||||
|
||||
// Already offline: a second call should report no further change.
|
||||
changed, err = s.MarkOfflineIfStale(ctx, "d1", -time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("mark offline (already offline): %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Fatal("expected no change once already offline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkOfflineIfStale_UnknownDeviceIsNoop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
changed, err := s.MarkOfflineIfStale(ctx, "ghost", time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Fatal("unknown device should never be reported as changed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /out/ingest-service ./cmd/ingest-service
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN adduser -D -u 10001 app
|
||||
COPY --from=build /out/ingest-service /usr/local/bin/ingest-service
|
||||
USER app
|
||||
ENTRYPOINT ["/usr/local/bin/ingest-service"]
|
||||
@@ -1,6 +1,6 @@
|
||||
# ingest-service (Go)
|
||||
|
||||
Статус: пока не реализован.
|
||||
Статус: реализован (MVP).
|
||||
|
||||
Зона ответственности:
|
||||
- Подписка на MQTT-топики `devices/{device_id}/telemetry`.
|
||||
@@ -11,3 +11,25 @@
|
||||
Не входит в зону ответственности: интерпретация того, что *означает*
|
||||
показание датчика (пороги, действия) — это задача rule-engine-service. Этот
|
||||
сервис только принимает и сохраняет данные.
|
||||
|
||||
Про `zone_id`: payload телеметрии несёт `zone_id` напрямую (публикующее
|
||||
устройство/эмулятор само знает свою зону), а не ingest-service ищет его в
|
||||
PostgreSQL. Это удерживает зависимости ingest-service в рамках
|
||||
MQTT/ClickHouse/RabbitMQ, как и на диаграмме архитектуры в корневом README.
|
||||
Для реального парка устройств, которые не будут знать свою зону,
|
||||
понадобится lookup через реестр устройств (с кэшированием, как rule-engine
|
||||
кэширует правила) — это естественное развитие, пока не реализовано.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
cd services/ingest-service
|
||||
go test ./...
|
||||
go build ./cmd/ingest-service
|
||||
```
|
||||
|
||||
Конфигурация читается из переменных окружения (см. секцию ingest-service в
|
||||
корневом `.env.example`). Через `docker compose up ingest-service` сервис
|
||||
обращается напрямую к контейнерам `mosquitto`/`clickhouse`/`rabbitmq`; для
|
||||
локального запуска через `go run` выставь `MQTT_HOST=localhost`,
|
||||
`CLICKHOUSE_HOST=localhost` и т.д.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Command ingest-service subscribes to device telemetry over MQTT, batches
|
||||
// it into ClickHouse, and publishes a "new reading" event per message for
|
||||
// rule-engine-service to consume.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.cactoz.su/cacto/home_automatization/services/ingest-service/internal/batch"
|
||||
chstore "git.cactoz.su/cacto/home_automatization/services/ingest-service/internal/clickhouse"
|
||||
"git.cactoz.su/cacto/home_automatization/services/ingest-service/internal/config"
|
||||
"git.cactoz.su/cacto/home_automatization/services/ingest-service/internal/mqttclient"
|
||||
"git.cactoz.su/cacto/home_automatization/services/ingest-service/internal/rabbitmq"
|
||||
"git.cactoz.su/cacto/home_automatization/services/ingest-service/internal/telemetry"
|
||||
)
|
||||
|
||||
// incomingBufferSize bounds how many parsed readings can wait for the worker
|
||||
// goroutine before the MQTT callback starts dropping messages. It exists so
|
||||
// a slow ClickHouse/RabbitMQ round-trip can't stall MQTT message delivery.
|
||||
const incomingBufferSize = 1000
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
if err := run(logger); err != nil {
|
||||
logger.Error("ingest-service exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(logger *slog.Logger) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
store, err := chstore.Connect(ctx, chstore.Config{
|
||||
Addr: cfg.ClickHouseAddr,
|
||||
Database: cfg.ClickHouseDatabase,
|
||||
Username: cfg.ClickHouseUsername,
|
||||
Password: cfg.ClickHousePassword,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
publisher, err := rabbitmq.Connect(cfg.RabbitMQURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer publisher.Close()
|
||||
|
||||
batcher := batch.New(cfg.BatchMaxSize, cfg.BatchFlushInterval, cfg.BatchFlushTimeout, store.InsertBatch, logger)
|
||||
batcher.Start()
|
||||
defer batcher.Stop()
|
||||
|
||||
incoming := make(chan telemetry.Reading, incomingBufferSize)
|
||||
|
||||
var workerWG sync.WaitGroup
|
||||
workerWG.Add(1)
|
||||
go func() {
|
||||
defer workerWG.Done()
|
||||
for reading := range incoming {
|
||||
batcher.Add(reading)
|
||||
|
||||
pubCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err := publisher.PublishReading(pubCtx, reading)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error("publish reading event failed", "device_id", reading.DeviceID, "error", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
subscriber, err := mqttclient.Connect(mqttclient.Config{
|
||||
BrokerURL: cfg.MQTTBrokerURL,
|
||||
ClientID: cfg.MQTTClientID,
|
||||
Topic: cfg.MQTTTopic,
|
||||
QoS: 1,
|
||||
}, func(payload []byte, receivedAt time.Time) {
|
||||
reading, err := telemetry.ParseReading(payload, receivedAt)
|
||||
if err != nil {
|
||||
logger.Warn("dropping invalid telemetry payload", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case incoming <- reading:
|
||||
default:
|
||||
logger.Error("dropping reading: worker backlog full", "device_id", reading.DeviceID)
|
||||
}
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("ingest-service started", "mqtt_topic", cfg.MQTTTopic)
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
logger.Info("shutting down")
|
||||
subscriber.Close()
|
||||
close(incoming)
|
||||
workerWG.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
module git.cactoz.su/cacto/home_automatization/services/ingest-service
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.47.0
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/rabbitmq/amqp091-go v1.13.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ClickHouse/ch-go v0.73.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/paulmach/orb v0.13.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
github.com/ClickHouse/ch-go v0.73.0 h1:jsHiGRbQ3sz+gekvDFJF29LWDo5dzbJm5s1h8TWVP2M=
|
||||
github.com/ClickHouse/ch-go v0.73.0/go.mod h1:wkFIxrqlXeRJ9cn3r5Fz5Qen9jl5aTMPuGZeuJpANNY=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.47.0 h1:ZDAzrnKSOPTIsm4tdUNfrii2yc8dk4SVRLC77BR7Z5Q=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.47.0/go.mod h1:sPj7C7UYQ2MWHcfX+4eGN6nwnCqwUKfgO6PcwKpd6K8=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
|
||||
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
|
||||
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rabbitmq/amqp091-go v1.13.0 h1:L8NA1WtF76C6KA3LAoufjfLgbist/If1UQYcsOjtxXA=
|
||||
github.com/rabbitmq/amqp091-go v1.13.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,105 @@
|
||||
// Package batch accumulates telemetry readings and flushes them in bulk,
|
||||
// either once a size threshold is hit or on a fixed interval — so ClickHouse
|
||||
// gets bulk inserts instead of one round-trip per reading.
|
||||
package batch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cactoz.su/cacto/home_automatization/services/ingest-service/internal/telemetry"
|
||||
)
|
||||
|
||||
// FlushFunc persists one batch. It is called with a context bounded by
|
||||
// FlushTimeout, both for interval-triggered and shutdown-triggered flushes.
|
||||
type FlushFunc func(ctx context.Context, readings []telemetry.Reading) error
|
||||
|
||||
// Batcher buffers readings in memory and flushes them via FlushFunc when
|
||||
// MaxSize is reached or Interval elapses, whichever comes first.
|
||||
type Batcher struct {
|
||||
maxSize int
|
||||
interval time.Duration
|
||||
flushTimeout time.Duration
|
||||
flush FlushFunc
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
buf []telemetry.Reading
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func New(maxSize int, interval, flushTimeout time.Duration, flush FlushFunc, logger *slog.Logger) *Batcher {
|
||||
return &Batcher{
|
||||
maxSize: maxSize,
|
||||
interval: interval,
|
||||
flushTimeout: flushTimeout,
|
||||
flush: flush,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the interval-flush loop. Call once before the first Add.
|
||||
func (b *Batcher) Start() {
|
||||
b.stop = make(chan struct{})
|
||||
b.done = make(chan struct{})
|
||||
go b.loop()
|
||||
}
|
||||
|
||||
func (b *Batcher) loop() {
|
||||
defer close(b.done)
|
||||
ticker := time.NewTicker(b.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
b.flushNow("interval")
|
||||
case <-b.stop:
|
||||
b.flushNow("shutdown")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add appends a reading, flushing immediately if the buffer just reached
|
||||
// maxSize rather than waiting for the next tick.
|
||||
func (b *Batcher) Add(r telemetry.Reading) {
|
||||
b.mu.Lock()
|
||||
b.buf = append(b.buf, r)
|
||||
full := len(b.buf) >= b.maxSize
|
||||
b.mu.Unlock()
|
||||
|
||||
if full {
|
||||
b.flushNow("size")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop flushes any remaining buffered readings and waits for the flush loop
|
||||
// to exit. Safe to call once, after Start.
|
||||
func (b *Batcher) Stop() {
|
||||
close(b.stop)
|
||||
<-b.done
|
||||
}
|
||||
|
||||
func (b *Batcher) flushNow(reason string) {
|
||||
b.mu.Lock()
|
||||
if len(b.buf) == 0 {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
readings := b.buf
|
||||
b.buf = nil
|
||||
b.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), b.flushTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := b.flush(ctx, readings); err != nil {
|
||||
b.logger.Error("batch flush failed", "reason", reason, "size", len(readings), "error", err)
|
||||
return
|
||||
}
|
||||
b.logger.Debug("batch flushed", "reason", reason, "size", len(readings))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cactoz.su/cacto/home_automatization/services/ingest-service/internal/telemetry"
|
||||
)
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func reading(deviceID string) telemetry.Reading {
|
||||
return telemetry.Reading{DeviceID: deviceID, ZoneID: "z1", SensorType: "temperature", Value: 1}
|
||||
}
|
||||
|
||||
func awaitBatch(t *testing.T, flushed chan []telemetry.Reading, timeout time.Duration) []telemetry.Reading {
|
||||
t.Helper()
|
||||
select {
|
||||
case batch := <-flushed:
|
||||
return batch
|
||||
case <-time.After(timeout):
|
||||
t.Fatal("timed out waiting for flush")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatcher_FlushesOnSize(t *testing.T) {
|
||||
flushed := make(chan []telemetry.Reading, 1)
|
||||
b := New(3, time.Hour, time.Second, func(_ context.Context, r []telemetry.Reading) error {
|
||||
flushed <- r
|
||||
return nil
|
||||
}, discardLogger())
|
||||
b.Start()
|
||||
defer b.Stop()
|
||||
|
||||
b.Add(reading("a"))
|
||||
b.Add(reading("b"))
|
||||
b.Add(reading("c")) // hits maxSize, should trigger an immediate flush
|
||||
|
||||
batch := awaitBatch(t, flushed, time.Second)
|
||||
if len(batch) != 3 {
|
||||
t.Fatalf("got %d readings, want 3", len(batch))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatcher_FlushesOnInterval(t *testing.T) {
|
||||
flushed := make(chan []telemetry.Reading, 1)
|
||||
b := New(100, 20*time.Millisecond, time.Second, func(_ context.Context, r []telemetry.Reading) error {
|
||||
flushed <- r
|
||||
return nil
|
||||
}, discardLogger())
|
||||
b.Start()
|
||||
defer b.Stop()
|
||||
|
||||
b.Add(reading("a"))
|
||||
|
||||
batch := awaitBatch(t, flushed, time.Second)
|
||||
if len(batch) != 1 {
|
||||
t.Fatalf("got %d readings, want 1", len(batch))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatcher_StopFlushesRemainder(t *testing.T) {
|
||||
flushed := make(chan []telemetry.Reading, 1)
|
||||
b := New(100, time.Hour, time.Second, func(_ context.Context, r []telemetry.Reading) error {
|
||||
flushed <- r
|
||||
return nil
|
||||
}, discardLogger())
|
||||
b.Start()
|
||||
|
||||
b.Add(reading("a"))
|
||||
b.Add(reading("b"))
|
||||
b.Stop()
|
||||
|
||||
batch := awaitBatch(t, flushed, time.Second)
|
||||
if len(batch) != 2 {
|
||||
t.Fatalf("got %d readings, want 2", len(batch))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package clickhouse writes batches of telemetry readings to ClickHouse.
|
||||
package clickhouse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
|
||||
ch "github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
|
||||
"git.cactoz.su/cacto/home_automatization/services/ingest-service/internal/telemetry"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
Database string
|
||||
Username string
|
||||
Password string
|
||||
UseTLS bool
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
conn driver.Conn
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, cfg Config) (*Store, error) {
|
||||
opts := &ch.Options{
|
||||
Addr: []string{cfg.Addr},
|
||||
Auth: ch.Auth{
|
||||
Database: cfg.Database,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
},
|
||||
}
|
||||
if cfg.UseTLS {
|
||||
opts.TLS = &tls.Config{}
|
||||
}
|
||||
|
||||
conn, err := ch.Open(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open clickhouse connection: %w", err)
|
||||
}
|
||||
if err := conn.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ping clickhouse: %w", err)
|
||||
}
|
||||
return &Store{conn: conn}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.conn.Close()
|
||||
}
|
||||
|
||||
// InsertBatch writes readings to the telemetry table in a single INSERT.
|
||||
func (s *Store) InsertBatch(ctx context.Context, readings []telemetry.Reading) error {
|
||||
if len(readings) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
batch, err := s.conn.PrepareBatch(ctx, "INSERT INTO telemetry (device_id, zone_id, sensor_type, value, recorded_at, received_at)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare batch: %w", err)
|
||||
}
|
||||
defer batch.Close()
|
||||
|
||||
for _, r := range readings {
|
||||
if err := batch.Append(r.DeviceID, r.ZoneID, r.SensorType, r.Value, r.RecordedAt, r.ReceivedAt); err != nil {
|
||||
return fmt.Errorf("append reading for device %s: %w", r.DeviceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := batch.Send(); err != nil {
|
||||
return fmt.Errorf("send batch: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package config loads ingest-service settings from environment variables,
|
||||
// matching the names used in the repo-root .env.example.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MQTTBrokerURL string
|
||||
MQTTClientID string
|
||||
MQTTTopic string
|
||||
|
||||
ClickHouseAddr string
|
||||
ClickHouseDatabase string
|
||||
ClickHouseUsername string
|
||||
ClickHousePassword string
|
||||
|
||||
RabbitMQURL string
|
||||
|
||||
BatchMaxSize int
|
||||
BatchFlushInterval time.Duration
|
||||
BatchFlushTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
MQTTBrokerURL: fmt.Sprintf("tcp://%s:%s", getEnv("MQTT_HOST", "localhost"), getEnv("MQTT_PORT", "1883")),
|
||||
MQTTClientID: getEnv("INGEST_MQTT_CLIENT_ID", "ingest-service"),
|
||||
MQTTTopic: getEnv("INGEST_MQTT_TOPIC", "devices/+/telemetry"),
|
||||
|
||||
ClickHouseAddr: fmt.Sprintf("%s:%s", getEnv("CLICKHOUSE_HOST", "localhost"), getEnv("CLICKHOUSE_NATIVE_PORT", "9000")),
|
||||
ClickHouseDatabase: getEnv("CLICKHOUSE_DB", "telemetry"),
|
||||
ClickHouseUsername: getEnv("CLICKHOUSE_USER", "default"),
|
||||
ClickHousePassword: getEnv("CLICKHOUSE_PASSWORD", ""),
|
||||
|
||||
RabbitMQURL: fmt.Sprintf("amqp://%s:%s@%s:%s/",
|
||||
getEnv("RABBITMQ_USER", "guest"),
|
||||
getEnv("RABBITMQ_PASSWORD", "guest"),
|
||||
getEnv("RABBITMQ_HOST", "localhost"),
|
||||
getEnv("RABBITMQ_PORT", "5672"),
|
||||
),
|
||||
}
|
||||
|
||||
var err error
|
||||
if cfg.BatchMaxSize, err = getEnvInt("INGEST_BATCH_MAX_SIZE", 500); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.BatchFlushInterval, err = getEnvDuration("INGEST_BATCH_FLUSH_INTERVAL", 5*time.Second); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.BatchFlushTimeout, err = getEnvDuration("INGEST_BATCH_FLUSH_TIMEOUT", 10*time.Second); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) (int, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, fallback time.Duration) (time.Duration, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package mqttclient subscribes to device telemetry topics over MQTT.
|
||||
package mqttclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// Handler receives one message's raw payload and the time it arrived.
|
||||
type Handler func(payload []byte, receivedAt time.Time)
|
||||
|
||||
type Config struct {
|
||||
BrokerURL string // e.g. tcp://mosquitto:1883
|
||||
ClientID string
|
||||
Topic string // e.g. devices/+/telemetry
|
||||
QoS byte
|
||||
}
|
||||
|
||||
type Subscriber struct {
|
||||
client mqtt.Client
|
||||
}
|
||||
|
||||
// Connect dials the broker and (re-)subscribes to cfg.Topic on every
|
||||
// successful connection, so a dropped connection resubscribes automatically
|
||||
// once auto-reconnect succeeds.
|
||||
func Connect(cfg Config, handler Handler, logger *slog.Logger) (*Subscriber, error) {
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(cfg.BrokerURL).
|
||||
SetClientID(cfg.ClientID).
|
||||
SetAutoReconnect(true).
|
||||
SetConnectRetry(true).
|
||||
SetOnConnectHandler(func(c mqtt.Client) {
|
||||
token := c.Subscribe(cfg.Topic, cfg.QoS, func(_ mqtt.Client, msg mqtt.Message) {
|
||||
handler(msg.Payload(), time.Now())
|
||||
})
|
||||
token.Wait()
|
||||
if err := token.Error(); err != nil {
|
||||
logger.Error("mqtt subscribe failed", "topic", cfg.Topic, "error", err)
|
||||
}
|
||||
}).
|
||||
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
|
||||
logger.Warn("mqtt connection lost", "error", err)
|
||||
})
|
||||
|
||||
client := mqtt.NewClient(opts)
|
||||
token := client.Connect()
|
||||
token.Wait()
|
||||
if err := token.Error(); err != nil {
|
||||
return nil, fmt.Errorf("connect to mqtt broker %s: %w", cfg.BrokerURL, err)
|
||||
}
|
||||
return &Subscriber{client: client}, nil
|
||||
}
|
||||
|
||||
// Close disconnects, waiting up to quiesceMS for in-flight work to settle.
|
||||
func (s *Subscriber) Close() {
|
||||
s.client.Disconnect(250)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package rabbitmq publishes "new reading" events for rule-engine-service to
|
||||
// consume. Delivery reliability matters more than latency here, so messages
|
||||
// are persistent and the queue is durable.
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
|
||||
"git.cactoz.su/cacto/home_automatization/services/ingest-service/internal/telemetry"
|
||||
)
|
||||
|
||||
const NewReadingQueue = "telemetry.new_reading"
|
||||
|
||||
type Publisher struct {
|
||||
conn *amqp.Connection
|
||||
ch *amqp.Channel
|
||||
}
|
||||
|
||||
func Connect(url string) (*Publisher, error) {
|
||||
conn, err := amqp.Dial(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial rabbitmq: %w", err)
|
||||
}
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("open channel: %w", err)
|
||||
}
|
||||
|
||||
if _, err := ch.QueueDeclare(NewReadingQueue, true, false, false, false, nil); err != nil {
|
||||
ch.Close()
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("declare queue %s: %w", NewReadingQueue, err)
|
||||
}
|
||||
|
||||
return &Publisher{conn: conn, ch: ch}, nil
|
||||
}
|
||||
|
||||
func (p *Publisher) Close() error {
|
||||
if err := p.ch.Close(); err != nil {
|
||||
p.conn.Close()
|
||||
return err
|
||||
}
|
||||
return p.conn.Close()
|
||||
}
|
||||
|
||||
// newReadingEvent is the wire format published to rule-engine-service.
|
||||
type newReadingEvent struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ZoneID string `json:"zone_id"`
|
||||
SensorType string `json:"sensor_type"`
|
||||
Value float64 `json:"value"`
|
||||
RecordedAt time.Time `json:"recorded_at"`
|
||||
}
|
||||
|
||||
// PublishReading emits one event per reading to NewReadingQueue.
|
||||
func (p *Publisher) PublishReading(ctx context.Context, r telemetry.Reading) error {
|
||||
body, err := json.Marshal(newReadingEvent{
|
||||
DeviceID: r.DeviceID,
|
||||
ZoneID: r.ZoneID,
|
||||
SensorType: r.SensorType,
|
||||
Value: r.Value,
|
||||
RecordedAt: r.RecordedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event: %w", err)
|
||||
}
|
||||
|
||||
err = p.ch.PublishWithContext(ctx, "", NewReadingQueue, false, false, amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish reading for device %s: %w", r.DeviceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package telemetry parses and validates raw device telemetry payloads.
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Reading is a single validated sensor reading, ready to be stored in
|
||||
// ClickHouse and published as an event for rule-engine-service.
|
||||
type Reading struct {
|
||||
DeviceID string
|
||||
ZoneID string
|
||||
SensorType string
|
||||
Value float64
|
||||
RecordedAt time.Time
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
// payload mirrors the wire format devices publish to devices/{device_id}/telemetry.
|
||||
//
|
||||
// ZoneID travels with the payload (rather than being looked up from
|
||||
// PostgreSQL) so ingest-service stays decoupled from the device registry,
|
||||
// matching the ingest→ClickHouse/RabbitMQ-only data flow in the platform
|
||||
// architecture — the publishing client is responsible for knowing its own zone.
|
||||
type payload struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ZoneID string `json:"zone_id"`
|
||||
SensorType string `json:"sensor_type"`
|
||||
Value float64 `json:"value"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ParseReading decodes and validates a raw MQTT message body. receivedAt is
|
||||
// the time the server received the message, stamped independently of
|
||||
// whatever the device claims, so ingest/processing delay can be measured later.
|
||||
func ParseReading(raw []byte, receivedAt time.Time) (Reading, error) {
|
||||
var p payload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return Reading{}, fmt.Errorf("invalid json: %w", err)
|
||||
}
|
||||
|
||||
if p.DeviceID == "" {
|
||||
return Reading{}, fmt.Errorf("device_id is required")
|
||||
}
|
||||
if p.ZoneID == "" {
|
||||
return Reading{}, fmt.Errorf("zone_id is required")
|
||||
}
|
||||
if p.SensorType == "" {
|
||||
return Reading{}, fmt.Errorf("sensor_type is required")
|
||||
}
|
||||
if p.Timestamp == "" {
|
||||
return Reading{}, fmt.Errorf("timestamp is required")
|
||||
}
|
||||
recordedAt, err := time.Parse(time.RFC3339, p.Timestamp)
|
||||
if err != nil {
|
||||
return Reading{}, fmt.Errorf("invalid timestamp %q: %w", p.Timestamp, err)
|
||||
}
|
||||
|
||||
return Reading{
|
||||
DeviceID: p.DeviceID,
|
||||
ZoneID: p.ZoneID,
|
||||
SensorType: p.SensorType,
|
||||
Value: p.Value,
|
||||
RecordedAt: recordedAt,
|
||||
ReceivedAt: receivedAt,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseReading_Valid(t *testing.T) {
|
||||
receivedAt := time.Date(2026, 7, 26, 12, 0, 5, 0, time.UTC)
|
||||
raw := []byte(`{
|
||||
"device_id": "esp32-abc123",
|
||||
"zone_id": "growbox-1",
|
||||
"sensor_type": "temperature",
|
||||
"value": 24.5,
|
||||
"timestamp": "2026-07-26T12:00:00Z"
|
||||
}`)
|
||||
|
||||
got, err := ParseReading(raw, receivedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
want := Reading{
|
||||
DeviceID: "esp32-abc123",
|
||||
ZoneID: "growbox-1",
|
||||
SensorType: "temperature",
|
||||
Value: 24.5,
|
||||
RecordedAt: time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC),
|
||||
ReceivedAt: receivedAt,
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReading_Invalid(t *testing.T) {
|
||||
receivedAt := time.Now()
|
||||
|
||||
cases := map[string]string{
|
||||
"malformed json": `{not json`,
|
||||
"missing device_id": `{
|
||||
"zone_id": "growbox-1", "sensor_type": "temperature",
|
||||
"value": 1, "timestamp": "2026-07-26T12:00:00Z"
|
||||
}`,
|
||||
"missing zone_id": `{
|
||||
"device_id": "esp32-1", "sensor_type": "temperature",
|
||||
"value": 1, "timestamp": "2026-07-26T12:00:00Z"
|
||||
}`,
|
||||
"missing sensor_type": `{
|
||||
"device_id": "esp32-1", "zone_id": "growbox-1",
|
||||
"value": 1, "timestamp": "2026-07-26T12:00:00Z"
|
||||
}`,
|
||||
"missing timestamp": `{
|
||||
"device_id": "esp32-1", "zone_id": "growbox-1",
|
||||
"sensor_type": "temperature", "value": 1
|
||||
}`,
|
||||
"invalid timestamp format": `{
|
||||
"device_id": "esp32-1", "zone_id": "growbox-1",
|
||||
"sensor_type": "temperature", "value": 1, "timestamp": "not-a-date"
|
||||
}`,
|
||||
"value wrong type": `{
|
||||
"device_id": "esp32-1", "zone_id": "growbox-1",
|
||||
"sensor_type": "temperature", "value": "not-a-number", "timestamp": "2026-07-26T12:00:00Z"
|
||||
}`,
|
||||
}
|
||||
|
||||
for name, raw := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := ParseReading([]byte(raw), receivedAt); err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Build context is the repo root (not this directory) — rule-engine-service
|
||||
# depends on the sibling proto/ Go module via a relative `replace` directive.
|
||||
FROM golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY proto/ ./proto/
|
||||
COPY services/rule-engine-service/ ./services/rule-engine-service/
|
||||
WORKDIR /src/services/rule-engine-service
|
||||
RUN go mod download
|
||||
RUN CGO_ENABLED=0 go build -o /out/rule-engine-service ./cmd/rule-engine-service
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN adduser -D -u 10001 app
|
||||
COPY --from=build /out/rule-engine-service /usr/local/bin/rule-engine-service
|
||||
USER app
|
||||
ENTRYPOINT ["/usr/local/bin/rule-engine-service"]
|
||||
@@ -1,18 +1,52 @@
|
||||
# rule-engine-service (Go)
|
||||
|
||||
Статус: пока не реализован.
|
||||
Статус: реализован (MVP).
|
||||
|
||||
Зона ответственности:
|
||||
- Потребляет события «новое показание» из RabbitMQ (асинхронно — здесь
|
||||
надёжность доставки важнее задержки).
|
||||
- Загружает активные `automation_rules` для нужной зоны/устройства из
|
||||
PostgreSQL, кэшируя в памяти/Redis, чтобы не ходить в БД на каждое событие.
|
||||
- Потребляет события «новое показание» из RabbitMQ (очередь
|
||||
`telemetry.new_reading`, публикуемая ingest-service) — асинхронно, с ручным
|
||||
ack/nack: надёжность доставки здесь важнее задержки.
|
||||
- Загружает активные `automation_rules` из PostgreSQL в кэш в памяти
|
||||
(обновляется по тикеру), чтобы не ходить в БД на каждое событие.
|
||||
- Оценивает условия правил обобщённо: `sensor_type X operator value` против
|
||||
показания — никаких зашитых названий датчиков/устройств, всё берётся из
|
||||
строки `automation_rules`.
|
||||
- При срабатывании: вызывает device-control-service по gRPC (нужен быстрый
|
||||
результат успех/неудача) и публикует событие в notification-service через
|
||||
RabbitMQ (не критично к задержке).
|
||||
результат успех/неудача) и публикует событие `automation.rule_triggered` в
|
||||
RabbitMQ для будущего notification-service (не критично к задержке).
|
||||
|
||||
Не входит в зону ответственности: прямое взаимодействие с MQTT или Redis —
|
||||
изменения состояния устройства всегда идут через gRPC API device-control-service.
|
||||
|
||||
## Архитектурные решения
|
||||
|
||||
- **automation_rules хранит внутренние ID Postgres, а не внешние device_id.**
|
||||
`condition_source_device_id`/`target_device_id` — это FK на `devices.id`
|
||||
(bigserial), а MQTT/Redis/gRPC везде оперируют `devices.external_id`
|
||||
(строка). Поэтому кэш правил грузится JOIN'ом `automation_rules` с
|
||||
`devices` (дважды — для source и target), резолвя оба ID в external_id
|
||||
один раз при загрузке, а не на каждое событие.
|
||||
- **Retry — только на транспортных ошибках.** Если gRPC-вызов
|
||||
device-control-service не удался физически (сеть, сервис недоступен) —
|
||||
событие из RabbitMQ nack'ается с `requeue=true`, всё правило
|
||||
переигрывается позже. Если устройство само отклонило команду
|
||||
(`CommandResult.success=false`, например неизвестный device_id) — это
|
||||
финальный исход, ack, повторов не будет. Осознанное упрощение: если из
|
||||
нескольких правил на одно показание одно не удалось из-за сети, при
|
||||
повторной доставке переиграются ВСЕ правила читающие это показание, включая
|
||||
уже успешно сработавшие — дедупликация не реализована, для MVP это
|
||||
приемлемо (команды идемпотентны: turn_on дважды — не проблема).
|
||||
- **Кэш правил — только для чтения активных правил**, никакой записи назад в
|
||||
Postgres. CRUD правил — это зона ответственности Laravel (этап 2).
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
cd services/rule-engine-service
|
||||
go test ./...
|
||||
go build ./cmd/rule-engine-service
|
||||
```
|
||||
|
||||
Конфигурация — через переменные окружения (секция rule-engine-service в
|
||||
корневом `.env.example`). Как и device-control-service, использует
|
||||
`proto/device_control` через `replace` на `../../proto` в `go.mod`.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Command rule-engine-service consumes telemetry readings from RabbitMQ,
|
||||
// evaluates them against PostgreSQL-backed automation rules, and dispatches
|
||||
// matching actions to device-control-service over gRPC.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/config"
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/devicecontrolclient"
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/engine"
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/rabbitmq"
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/rulecache"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
if err := run(logger); err != nil {
|
||||
logger.Error("rule-engine-service exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(logger *slog.Logger) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
pgPool, err := pgxpool.New(ctx, cfg.PostgresDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pgPool.Close()
|
||||
|
||||
dcClient, err := devicecontrolclient.Connect(cfg.DeviceControlGRPCTarget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dcClient.Close()
|
||||
|
||||
mq, err := rabbitmq.Connect(cfg.RabbitMQURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer mq.Close()
|
||||
|
||||
cache := rulecache.New(rulecache.NewPostgresFetcher(pgPool), cfg.RuleCacheRefreshInterval, logger)
|
||||
if err := cache.Start(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
defer cache.Stop()
|
||||
|
||||
eng := engine.New(cache, dcClient, mq, logger)
|
||||
|
||||
logger.Info("rule-engine-service started")
|
||||
|
||||
err = mq.ConsumeReadings(ctx, func(ctx context.Context, event rabbitmq.ReadingEvent) rabbitmq.HandleResult {
|
||||
reading := engine.Reading{
|
||||
DeviceID: event.DeviceID,
|
||||
ZoneID: event.ZoneID,
|
||||
SensorType: event.SensorType,
|
||||
Value: event.Value,
|
||||
RecordedAt: event.RecordedAt,
|
||||
}
|
||||
if err := eng.HandleReading(ctx, reading); err != nil {
|
||||
logger.Error("handling reading failed, will retry", "device_id", event.DeviceID, "error", err)
|
||||
return rabbitmq.NackRequeue
|
||||
}
|
||||
return rabbitmq.Ack
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("shutting down")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
module git.cactoz.su/cacto/home_automatization/services/rule-engine-service
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.cactoz.su/cacto/home_automatization/proto v0.0.0-00010101000000-000000000000
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/rabbitmq/amqp091-go v1.13.0
|
||||
google.golang.org/grpc v1.82.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
replace git.cactoz.su/cacto/home_automatization/proto => ../../proto
|
||||
@@ -0,0 +1,66 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rabbitmq/amqp091-go v1.13.0 h1:L8NA1WtF76C6KA3LAoufjfLgbist/If1UQYcsOjtxXA=
|
||||
github.com/rabbitmq/amqp091-go v1.13.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package config loads rule-engine-service settings from environment
|
||||
// variables, matching the names used in the repo-root .env.example.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
PostgresDSN string
|
||||
|
||||
DeviceControlGRPCTarget string
|
||||
|
||||
RabbitMQURL string
|
||||
|
||||
RuleCacheRefreshInterval time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
PostgresDSN: fmt.Sprintf("postgres://%s:%s@%s:%s/%s",
|
||||
getEnv("POSTGRES_USER", "home_automation"),
|
||||
getEnv("POSTGRES_PASSWORD", ""),
|
||||
getEnv("POSTGRES_HOST", "localhost"),
|
||||
getEnv("POSTGRES_PORT", "5432"),
|
||||
getEnv("POSTGRES_DB", "home_automation"),
|
||||
),
|
||||
|
||||
DeviceControlGRPCTarget: fmt.Sprintf("%s:%s",
|
||||
getEnv("DEVICE_CONTROL_HOST", "localhost"),
|
||||
getEnv("DEVICE_CONTROL_GRPC_PORT", "50051"),
|
||||
),
|
||||
|
||||
RabbitMQURL: fmt.Sprintf("amqp://%s:%s@%s:%s/",
|
||||
getEnv("RABBITMQ_USER", "guest"),
|
||||
getEnv("RABBITMQ_PASSWORD", "guest"),
|
||||
getEnv("RABBITMQ_HOST", "localhost"),
|
||||
getEnv("RABBITMQ_PORT", "5672"),
|
||||
),
|
||||
}
|
||||
|
||||
interval, err := getEnvDuration("RULE_ENGINE_CACHE_REFRESH_INTERVAL", 15*time.Second)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.RuleCacheRefreshInterval = interval
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, fallback time.Duration) (time.Duration, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package engine orchestrates the core rule-engine loop: given one sensor
|
||||
// reading, find the rules that watch it, evaluate their conditions, and
|
||||
// dispatch the matching actions. It knows nothing about RabbitMQ, gRPC, or
|
||||
// PostgreSQL directly — those are injected as narrow interfaces so this
|
||||
// package is testable without any of them running.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/devicecontrolclient"
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/rules"
|
||||
)
|
||||
|
||||
// Reading is the generic sensor reading the engine reacts to — no
|
||||
// growbox/device-type-specific fields, matching the platform's abstractions.
|
||||
type Reading struct {
|
||||
DeviceID string
|
||||
ZoneID string
|
||||
SensorType string
|
||||
Value float64
|
||||
RecordedAt time.Time
|
||||
}
|
||||
|
||||
// RuleSource is the subset of rulecache.Cache the engine needs.
|
||||
type RuleSource interface {
|
||||
MatchingRules(deviceID, sensorType string) []rules.Rule
|
||||
}
|
||||
|
||||
// Dispatcher is the subset of devicecontrolclient.Client the engine needs.
|
||||
type Dispatcher interface {
|
||||
Dispatch(ctx context.Context, deviceID, actionType string, params map[string]any) (devicecontrolclient.Result, error)
|
||||
}
|
||||
|
||||
// TriggerPublisher is the subset of rabbitmq.Client the engine needs.
|
||||
type TriggerPublisher interface {
|
||||
PublishRuleTriggered(ctx context.Context, ruleID, zoneID int64, deviceID, actionType string, success bool, errMsg string) error
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
rules RuleSource
|
||||
dispatcher Dispatcher
|
||||
publisher TriggerPublisher
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func New(ruleSource RuleSource, dispatcher Dispatcher, publisher TriggerPublisher, logger *slog.Logger) *Engine {
|
||||
return &Engine{rules: ruleSource, dispatcher: dispatcher, publisher: publisher, logger: logger}
|
||||
}
|
||||
|
||||
// HandleReading evaluates every rule watching (r.DeviceID, r.SensorType) and
|
||||
// dispatches the ones whose condition matches. It returns a non-nil error
|
||||
// only when a dispatch failed at the transport level (e.g.
|
||||
// device-control-service unreachable) — that's the one case worth retrying
|
||||
// the whole reading for. A rule the device itself rejected (bad device_id,
|
||||
// unsupported action) is terminal and does not cause a retry.
|
||||
func (e *Engine) HandleReading(ctx context.Context, r Reading) error {
|
||||
matched := e.rules.MatchingRules(r.DeviceID, r.SensorType)
|
||||
|
||||
var firstTransportErr error
|
||||
for _, rule := range matched {
|
||||
matchesCondition, err := rules.Evaluate(rule.ConditionOperator, r.Value, rule.ConditionValue)
|
||||
if err != nil {
|
||||
e.logger.Error("skipping rule with invalid condition", "rule_id", rule.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if !matchesCondition {
|
||||
continue
|
||||
}
|
||||
|
||||
result, dispatchErr := e.dispatcher.Dispatch(ctx, rule.TargetDeviceID, rule.ActionType, rule.ActionParams)
|
||||
|
||||
success := dispatchErr == nil && result.Success
|
||||
errMsg := result.Error
|
||||
switch {
|
||||
case dispatchErr != nil:
|
||||
errMsg = dispatchErr.Error()
|
||||
e.logger.Error("dispatch action failed", "rule_id", rule.ID, "target_device_id", rule.TargetDeviceID, "error", dispatchErr)
|
||||
if firstTransportErr == nil {
|
||||
firstTransportErr = dispatchErr
|
||||
}
|
||||
case !result.Success:
|
||||
e.logger.Warn("device rejected command", "rule_id", rule.ID, "target_device_id", rule.TargetDeviceID, "error", result.Error)
|
||||
default:
|
||||
e.logger.Info("rule triggered", "rule_id", rule.ID, "target_device_id", rule.TargetDeviceID, "action_type", rule.ActionType)
|
||||
}
|
||||
|
||||
if pubErr := e.publisher.PublishRuleTriggered(ctx, rule.ID, rule.ZoneID, rule.TargetDeviceID, rule.ActionType, success, errMsg); pubErr != nil {
|
||||
e.logger.Error("publish rule_triggered event failed", "rule_id", rule.ID, "error", pubErr)
|
||||
}
|
||||
}
|
||||
|
||||
return firstTransportErr
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/devicecontrolclient"
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/rules"
|
||||
)
|
||||
|
||||
type fakeRuleSource struct {
|
||||
rules []rules.Rule
|
||||
}
|
||||
|
||||
func (f *fakeRuleSource) MatchingRules(_, _ string) []rules.Rule {
|
||||
return f.rules
|
||||
}
|
||||
|
||||
type dispatchCall struct {
|
||||
deviceID string
|
||||
actionType string
|
||||
params map[string]any
|
||||
}
|
||||
|
||||
type fakeDispatcher struct {
|
||||
calls []dispatchCall
|
||||
result devicecontrolclient.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDispatcher) Dispatch(_ context.Context, deviceID, actionType string, params map[string]any) (devicecontrolclient.Result, error) {
|
||||
f.calls = append(f.calls, dispatchCall{deviceID: deviceID, actionType: actionType, params: params})
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
type triggeredEvent struct {
|
||||
ruleID int64
|
||||
deviceID string
|
||||
actionType string
|
||||
success bool
|
||||
errMsg string
|
||||
}
|
||||
|
||||
type fakePublisher struct {
|
||||
events []triggeredEvent
|
||||
}
|
||||
|
||||
func (f *fakePublisher) PublishRuleTriggered(_ context.Context, ruleID, _ int64, deviceID, actionType string, success bool, errMsg string) error {
|
||||
f.events = append(f.events, triggeredEvent{ruleID: ruleID, deviceID: deviceID, actionType: actionType, success: success, errMsg: errMsg})
|
||||
return nil
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestHandleReading_NoMatchingRules(t *testing.T) {
|
||||
dispatcher := &fakeDispatcher{}
|
||||
publisher := &fakePublisher{}
|
||||
e := New(&fakeRuleSource{}, dispatcher, publisher, discardLogger())
|
||||
|
||||
if err := e.HandleReading(context.Background(), Reading{DeviceID: "sensor-1", SensorType: "temperature", Value: 30}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(dispatcher.calls) != 0 || len(publisher.events) != 0 {
|
||||
t.Fatalf("expected no dispatch/publish, got %+v / %+v", dispatcher.calls, publisher.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReading_ConditionNotMet(t *testing.T) {
|
||||
source := &fakeRuleSource{rules: []rules.Rule{
|
||||
{ID: 1, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
|
||||
}}
|
||||
dispatcher := &fakeDispatcher{}
|
||||
e := New(source, dispatcher, &fakePublisher{}, discardLogger())
|
||||
|
||||
if err := e.HandleReading(context.Background(), Reading{Value: 20}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(dispatcher.calls) != 0 {
|
||||
t.Fatalf("expected no dispatch when condition not met, got %+v", dispatcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReading_ConditionMet_DispatchesAndPublishes(t *testing.T) {
|
||||
source := &fakeRuleSource{rules: []rules.Rule{
|
||||
{ID: 1, ZoneID: 7, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
|
||||
}}
|
||||
dispatcher := &fakeDispatcher{result: devicecontrolclient.Result{Success: true}}
|
||||
publisher := &fakePublisher{}
|
||||
e := New(source, dispatcher, publisher, discardLogger())
|
||||
|
||||
if err := e.HandleReading(context.Background(), Reading{Value: 30}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(dispatcher.calls) != 1 || dispatcher.calls[0].deviceID != "fan-1" || dispatcher.calls[0].actionType != "turn_on" {
|
||||
t.Fatalf("got dispatch calls %+v, want one turn_on for fan-1", dispatcher.calls)
|
||||
}
|
||||
if len(publisher.events) != 1 || !publisher.events[0].success {
|
||||
t.Fatalf("got publish events %+v, want one success event", publisher.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReading_BusinessRejection_NotRetried(t *testing.T) {
|
||||
source := &fakeRuleSource{rules: []rules.Rule{
|
||||
{ID: 1, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
|
||||
}}
|
||||
dispatcher := &fakeDispatcher{result: devicecontrolclient.Result{Success: false, Error: "device offline"}}
|
||||
publisher := &fakePublisher{}
|
||||
e := New(source, dispatcher, publisher, discardLogger())
|
||||
|
||||
err := e.HandleReading(context.Background(), Reading{Value: 30})
|
||||
if err != nil {
|
||||
t.Fatalf("business rejection should not be reported as a retryable error, got %v", err)
|
||||
}
|
||||
if len(publisher.events) != 1 || publisher.events[0].success || publisher.events[0].errMsg != "device offline" {
|
||||
t.Fatalf("got publish events %+v, want one failed event with device offline", publisher.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReading_TransportError_IsRetryable(t *testing.T) {
|
||||
source := &fakeRuleSource{rules: []rules.Rule{
|
||||
{ID: 1, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
|
||||
}}
|
||||
dispatcher := &fakeDispatcher{err: errors.New("connection refused")}
|
||||
publisher := &fakePublisher{}
|
||||
e := New(source, dispatcher, publisher, discardLogger())
|
||||
|
||||
err := e.HandleReading(context.Background(), Reading{Value: 30})
|
||||
if err == nil {
|
||||
t.Fatal("expected a retryable error when dispatch fails at the transport level")
|
||||
}
|
||||
if len(publisher.events) != 1 || publisher.events[0].success {
|
||||
t.Fatalf("got publish events %+v, want one failed event recorded even on transport error", publisher.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReading_InvalidOperatorSkipsRuleButContinues(t *testing.T) {
|
||||
source := &fakeRuleSource{rules: []rules.Rule{
|
||||
{ID: 1, ConditionOperator: "~=", ConditionValue: 28, TargetDeviceID: "bad-rule"},
|
||||
{ID: 2, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
|
||||
}}
|
||||
dispatcher := &fakeDispatcher{result: devicecontrolclient.Result{Success: true}}
|
||||
e := New(source, dispatcher, &fakePublisher{}, discardLogger())
|
||||
|
||||
if err := e.HandleReading(context.Background(), Reading{Value: 30}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(dispatcher.calls) != 1 || dispatcher.calls[0].deviceID != "fan-1" {
|
||||
t.Fatalf("got dispatch calls %+v, want only the valid rule dispatched", dispatcher.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package rabbitmq consumes ingest-service's "new reading" events and
|
||||
// publishes rule-trigger events for notification-service. One connection is
|
||||
// shared, with separate channels for consuming and publishing, since that's
|
||||
// the amqp best practice (a channel is not meant to be used concurrently for
|
||||
// unrelated flows).
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const (
|
||||
NewReadingQueue = "telemetry.new_reading" // declared by ingest-service; declared here too, idempotently
|
||||
RuleTriggeredQueue = "automation.rule_triggered"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
conn *amqp.Connection
|
||||
consumeCh *amqp.Channel
|
||||
publishCh *amqp.Channel
|
||||
}
|
||||
|
||||
func Connect(url string) (*Client, error) {
|
||||
conn, err := amqp.Dial(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial rabbitmq: %w", err)
|
||||
}
|
||||
|
||||
consumeCh, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("open consume channel: %w", err)
|
||||
}
|
||||
// Process one reading at a time: simple, demonstrable backpressure —
|
||||
// the next delivery isn't sent until the current one is acked/nacked.
|
||||
if err := consumeCh.Qos(1, 0, false); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("set qos: %w", err)
|
||||
}
|
||||
if _, err := consumeCh.QueueDeclare(NewReadingQueue, true, false, false, false, nil); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("declare queue %s: %w", NewReadingQueue, err)
|
||||
}
|
||||
|
||||
publishCh, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("open publish channel: %w", err)
|
||||
}
|
||||
if _, err := publishCh.QueueDeclare(RuleTriggeredQueue, true, false, false, false, nil); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("declare queue %s: %w", RuleTriggeredQueue, err)
|
||||
}
|
||||
|
||||
return &Client{conn: conn, consumeCh: consumeCh, publishCh: publishCh}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReadingEvent mirrors ingest-service's newReadingEvent wire format.
|
||||
type ReadingEvent struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ZoneID string `json:"zone_id"`
|
||||
SensorType string `json:"sensor_type"`
|
||||
Value float64 `json:"value"`
|
||||
RecordedAt time.Time `json:"recorded_at"`
|
||||
}
|
||||
|
||||
// HandleResult tells ConsumeReadings how to settle a delivery.
|
||||
type HandleResult int
|
||||
|
||||
const (
|
||||
Ack HandleResult = iota // processed successfully
|
||||
NackRequeue // transient failure (e.g. gRPC unreachable) — try again later
|
||||
NackDiscard // permanent failure (e.g. bad data) — retrying won't help
|
||||
)
|
||||
|
||||
// Handler processes one reading event and decides how it should be settled.
|
||||
type Handler func(ctx context.Context, event ReadingEvent) HandleResult
|
||||
|
||||
// ConsumeReadings blocks, delivering messages to handler, until ctx is
|
||||
// cancelled or the delivery channel closes (e.g. connection lost).
|
||||
func (c *Client) ConsumeReadings(ctx context.Context, handler Handler) error {
|
||||
deliveries, err := c.consumeCh.ConsumeWithContext(ctx, NewReadingQueue, "", false, false, false, false, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume %s: %w", NewReadingQueue, err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case d, ok := <-deliveries:
|
||||
if !ok {
|
||||
return fmt.Errorf("delivery channel for %s closed", NewReadingQueue)
|
||||
}
|
||||
|
||||
var event ReadingEvent
|
||||
if err := json.Unmarshal(d.Body, &event); err != nil {
|
||||
// Poison message: no amount of retrying fixes malformed JSON.
|
||||
_ = d.Nack(false, false)
|
||||
continue
|
||||
}
|
||||
|
||||
switch handler(ctx, event) {
|
||||
case Ack:
|
||||
_ = d.Ack(false)
|
||||
case NackRequeue:
|
||||
_ = d.Nack(false, true)
|
||||
case NackDiscard:
|
||||
_ = d.Nack(false, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type ruleTriggeredEvent struct {
|
||||
RuleID int64 `json:"rule_id"`
|
||||
ZoneID int64 `json:"zone_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ActionType string `json:"action_type"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// PublishRuleTriggered emits one event per rule match+dispatch attempt, so
|
||||
// notification-service can (eventually) alert on it.
|
||||
func (c *Client) PublishRuleTriggered(ctx context.Context, ruleID, zoneID int64, deviceID, actionType string, success bool, errMsg string) error {
|
||||
body, err := json.Marshal(ruleTriggeredEvent{
|
||||
RuleID: ruleID,
|
||||
ZoneID: zoneID,
|
||||
DeviceID: deviceID,
|
||||
ActionType: actionType,
|
||||
Success: success,
|
||||
Error: errMsg,
|
||||
At: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event: %w", err)
|
||||
}
|
||||
|
||||
err = c.publishCh.PublishWithContext(ctx, "", RuleTriggeredQueue, false, false, amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish rule triggered event for rule %d: %w", ruleID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package rulecache keeps active automation rules in memory, refreshed
|
||||
// periodically from PostgreSQL, so the hot path (one Redis/gRPC-free lookup
|
||||
// per incoming reading) never needs a database round-trip.
|
||||
package rulecache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/rules"
|
||||
)
|
||||
|
||||
// Row is one automation_rules record already joined against devices, so
|
||||
// SourceExternalID/TargetExternalID are the external device IDs used by
|
||||
// MQTT/Redis/gRPC — not PostgreSQL's internal bigserial IDs.
|
||||
type Row struct {
|
||||
RuleID int64
|
||||
ZoneID int64
|
||||
SourceExternalID string
|
||||
TargetExternalID string
|
||||
ConditionSensorType string
|
||||
ConditionOperator string
|
||||
ConditionValue float64
|
||||
ActionType string
|
||||
ActionParams []byte // raw JSON, decoded during index build
|
||||
}
|
||||
|
||||
// Fetcher loads all currently active rules. The real implementation queries
|
||||
// PostgreSQL; tests supply a fake.
|
||||
type Fetcher interface {
|
||||
FetchActiveRules(ctx context.Context) ([]Row, error)
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
fetcher Fetcher
|
||||
interval time.Duration
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
index map[string][]rules.Rule
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func New(fetcher Fetcher, interval time.Duration, logger *slog.Logger) *Cache {
|
||||
return &Cache{fetcher: fetcher, interval: interval, logger: logger}
|
||||
}
|
||||
|
||||
// Start performs a synchronous initial load (so the service never runs with
|
||||
// an empty cache) and then begins the periodic refresh loop.
|
||||
func (c *Cache) Start(ctx context.Context) error {
|
||||
if err := c.refresh(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
c.stop = make(chan struct{})
|
||||
c.done = make(chan struct{})
|
||||
go c.loop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) Stop() {
|
||||
close(c.stop)
|
||||
<-c.done
|
||||
}
|
||||
|
||||
func (c *Cache) loop() {
|
||||
defer close(c.done)
|
||||
ticker := time.NewTicker(c.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.interval)
|
||||
if err := c.refresh(ctx); err != nil {
|
||||
c.logger.Error("rule cache refresh failed", "error", err)
|
||||
}
|
||||
cancel()
|
||||
case <-c.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) refresh(ctx context.Context) error {
|
||||
dbRows, err := c.fetcher.FetchActiveRules(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index := buildIndex(dbRows, c.logger)
|
||||
c.mu.Lock()
|
||||
c.index = index
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// MatchingRules returns the active rules that watch sensorType readings from
|
||||
// deviceID. Safe for concurrent use.
|
||||
func (c *Cache) MatchingRules(deviceID, sensorType string) []rules.Rule {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.index[cacheKey(deviceID, sensorType)]
|
||||
}
|
||||
|
||||
func cacheKey(deviceID, sensorType string) string {
|
||||
return deviceID + "\x00" + sensorType
|
||||
}
|
||||
|
||||
func buildIndex(dbRows []Row, logger *slog.Logger) map[string][]rules.Rule {
|
||||
index := make(map[string][]rules.Rule)
|
||||
for _, r := range dbRows {
|
||||
var params map[string]any
|
||||
if len(r.ActionParams) > 0 {
|
||||
if err := json.Unmarshal(r.ActionParams, ¶ms); err != nil {
|
||||
logger.Error("skipping rule with invalid action_params", "rule_id", r.RuleID, "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
rule := rules.Rule{
|
||||
ID: r.RuleID,
|
||||
ZoneID: r.ZoneID,
|
||||
SourceDeviceID: r.SourceExternalID,
|
||||
ConditionSensorType: r.ConditionSensorType,
|
||||
ConditionOperator: r.ConditionOperator,
|
||||
ConditionValue: r.ConditionValue,
|
||||
TargetDeviceID: r.TargetExternalID,
|
||||
ActionType: r.ActionType,
|
||||
ActionParams: params,
|
||||
}
|
||||
|
||||
key := cacheKey(r.SourceExternalID, r.ConditionSensorType)
|
||||
index[key] = append(index[key], rule)
|
||||
}
|
||||
return index
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package rulecache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeFetcher struct {
|
||||
mu sync.Mutex
|
||||
rows []Row
|
||||
calls chan struct{}
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) setRows(rows []Row) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.rows = rows
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchActiveRules(context.Context) ([]Row, error) {
|
||||
f.mu.Lock()
|
||||
rows := append([]Row(nil), f.rows...)
|
||||
f.mu.Unlock()
|
||||
if f.calls != nil {
|
||||
f.calls <- struct{}{}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestCache_StartLoadsSynchronously(t *testing.T) {
|
||||
fetcher := &fakeFetcher{rows: []Row{
|
||||
{RuleID: 1, SourceExternalID: "sensor-1", ConditionSensorType: "temperature", ConditionOperator: ">", ConditionValue: 28, TargetExternalID: "fan-1", ActionType: "turn_on"},
|
||||
}}
|
||||
c := New(fetcher, time.Hour, discardLogger())
|
||||
|
||||
if err := c.Start(context.Background()); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer c.Stop()
|
||||
|
||||
got := c.MatchingRules("sensor-1", "temperature")
|
||||
if len(got) != 1 || got[0].TargetDeviceID != "fan-1" {
|
||||
t.Fatalf("got %+v, want one rule targeting fan-1", got)
|
||||
}
|
||||
|
||||
// Different sensor_type on the same device must not match.
|
||||
if got := c.MatchingRules("sensor-1", "humidity"); len(got) != 0 {
|
||||
t.Fatalf("got %+v, want no rules for humidity", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_SkipsRuleWithInvalidActionParams(t *testing.T) {
|
||||
fetcher := &fakeFetcher{rows: []Row{
|
||||
{RuleID: 1, SourceExternalID: "sensor-1", ConditionSensorType: "temperature", ActionParams: []byte(`not json`)},
|
||||
{RuleID: 2, SourceExternalID: "sensor-1", ConditionSensorType: "temperature", ActionParams: []byte(`{"level":50}`)},
|
||||
}}
|
||||
c := New(fetcher, time.Hour, discardLogger())
|
||||
if err := c.Start(context.Background()); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer c.Stop()
|
||||
|
||||
got := c.MatchingRules("sensor-1", "temperature")
|
||||
if len(got) != 1 || got[0].ID != 2 {
|
||||
t.Fatalf("got %+v, want only rule 2 to survive", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_RefreshesOnInterval(t *testing.T) {
|
||||
calls := make(chan struct{}, 4)
|
||||
fetcher := &fakeFetcher{calls: calls}
|
||||
c := New(fetcher, 20*time.Millisecond, discardLogger())
|
||||
if err := c.Start(context.Background()); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer c.Stop()
|
||||
|
||||
<-calls // consume the initial synchronous load
|
||||
|
||||
fetcher.setRows([]Row{
|
||||
{RuleID: 1, SourceExternalID: "sensor-1", ConditionSensorType: "temperature", TargetExternalID: "fan-1", ActionType: "turn_on"},
|
||||
})
|
||||
|
||||
select {
|
||||
case <-calls:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for periodic refresh")
|
||||
}
|
||||
|
||||
got := c.MatchingRules("sensor-1", "temperature")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %+v after refresh, want the newly added rule", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_StopWaitsForLoopExit(t *testing.T) {
|
||||
c := New(&fakeFetcher{}, time.Hour, discardLogger())
|
||||
if err := c.Start(context.Background()); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
c.Stop() // must return without hanging
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package rulecache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// activeRulesQuery resolves condition_source_device_id/target_device_id (FKs
|
||||
// into devices.id) to devices.external_id, since every other part of the
|
||||
// platform (MQTT, Redis, gRPC) keys devices by their external ID, not
|
||||
// PostgreSQL's internal bigserial ID.
|
||||
const activeRulesQuery = `
|
||||
SELECT
|
||||
r.id AS rule_id,
|
||||
r.zone_id AS zone_id,
|
||||
src.external_id AS source_external_id,
|
||||
tgt.external_id AS target_external_id,
|
||||
r.condition_sensor_type AS condition_sensor_type,
|
||||
r.condition_operator AS condition_operator,
|
||||
r.condition_value AS condition_value,
|
||||
r.action_type AS action_type,
|
||||
r.action_params AS action_params
|
||||
FROM automation_rules r
|
||||
JOIN devices src ON src.id = r.condition_source_device_id
|
||||
JOIN devices tgt ON tgt.id = r.target_device_id
|
||||
WHERE r.is_active = true
|
||||
`
|
||||
|
||||
type PostgresFetcher struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPostgresFetcher(pool *pgxpool.Pool) *PostgresFetcher {
|
||||
return &PostgresFetcher{pool: pool}
|
||||
}
|
||||
|
||||
func (f *PostgresFetcher) FetchActiveRules(ctx context.Context) ([]Row, error) {
|
||||
dbRows, err := f.pool.Query(ctx, activeRulesQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query active rules: %w", err)
|
||||
}
|
||||
rows, err := pgx.CollectRows(dbRows, pgx.RowToStructByName[Row])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan active rules: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Package rules holds the generic automation-rule model and condition
|
||||
// evaluation logic. Nothing here knows about growboxes, lights, or pumps —
|
||||
// only sensor_type/operator/value, exactly as stored in automation_rules.
|
||||
package rules
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Rule mirrors one row of automation_rules, joined against devices so the
|
||||
// device IDs are the external identifiers used by MQTT/Redis/gRPC (not
|
||||
// PostgreSQL's internal bigserial IDs).
|
||||
type Rule struct {
|
||||
ID int64
|
||||
ZoneID int64
|
||||
SourceDeviceID string // condition_source_device_id, resolved to devices.external_id
|
||||
ConditionSensorType string
|
||||
ConditionOperator string
|
||||
ConditionValue float64
|
||||
TargetDeviceID string // target_device_id, resolved to devices.external_id
|
||||
ActionType string
|
||||
ActionParams map[string]any
|
||||
}
|
||||
|
||||
// Evaluate reports whether value satisfies the rule's condition.
|
||||
func Evaluate(operator string, value, threshold float64) (bool, error) {
|
||||
switch operator {
|
||||
case ">":
|
||||
return value > threshold, nil
|
||||
case "<":
|
||||
return value < threshold, nil
|
||||
case ">=":
|
||||
return value >= threshold, nil
|
||||
case "<=":
|
||||
return value <= threshold, nil
|
||||
case "=":
|
||||
return value == threshold, nil
|
||||
case "!=":
|
||||
return value != threshold, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown condition operator %q", operator)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package rules
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEvaluate(t *testing.T) {
|
||||
cases := []struct {
|
||||
operator string
|
||||
value float64
|
||||
threshold float64
|
||||
want bool
|
||||
}{
|
||||
{">", 29, 28, true},
|
||||
{">", 27, 28, false},
|
||||
{"<", 27, 28, true},
|
||||
{"<", 29, 28, false},
|
||||
{">=", 28, 28, true},
|
||||
{"<=", 28, 28, true},
|
||||
{"=", 28, 28, true},
|
||||
{"=", 28.1, 28, false},
|
||||
{"!=", 28.1, 28, true},
|
||||
{"!=", 28, 28, false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got, err := Evaluate(c.operator, c.value, c.threshold)
|
||||
if err != nil {
|
||||
t.Fatalf("operator %q: unexpected error: %v", c.operator, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("Evaluate(%q, %v, %v) = %v, want %v", c.operator, c.value, c.threshold, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_UnknownOperator(t *testing.T) {
|
||||
if _, err := Evaluate("~=", 1, 2); err == nil {
|
||||
t.Fatal("expected error for unknown operator")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user