174 lines
5.6 KiB
Go
174 lines
5.6 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// Create вставляет новое устройство. Конфликт (namespace, name) или
|
|
// (namespace, device_id) возвращает ErrAlreadyExists.
|
|
func (s *DeviceStore) Create(ctx context.Context, d *Device) error {
|
|
metadata, err := json.Marshal(d.Metadata)
|
|
if err != nil {
|
|
return fmt.Errorf("store: marshal metadata: %w", err)
|
|
}
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
d.Namespace, d.Name, d.DeviceID, d.Enabled, d.MQTTPassword, metadata,
|
|
)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return ErrAlreadyExists
|
|
}
|
|
return fmt.Errorf("store: insert device: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List возвращает устройства namespace (без сортировки по паролям — пароли на месте, но API их не отдаёт).
|
|
func (s *DeviceStore) List(ctx context.Context, namespace string) ([]Device, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase,
|
|
last_connected, created_at
|
|
FROM iot_devices WHERE namespace = $1 ORDER BY name`, namespace)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list devices: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanDevices(rows)
|
|
}
|
|
|
|
// GetByName возвращает устройство по (namespace, name).
|
|
func (s *DeviceStore) GetByName(ctx context.Context, namespace, name string) (*Device, error) {
|
|
return s.get(ctx, `SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase,
|
|
last_connected, created_at
|
|
FROM iot_devices WHERE namespace = $1 AND name = $2`, namespace, name)
|
|
}
|
|
|
|
// GetByDeviceID возвращает устройство по (namespace, device_id) — для MQTT auth.
|
|
func (s *DeviceStore) GetByDeviceID(ctx context.Context, namespace, deviceID string) (*Device, error) {
|
|
return s.get(ctx, `SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase,
|
|
last_connected, created_at
|
|
FROM iot_devices WHERE namespace = $1 AND device_id = $2`, namespace, deviceID)
|
|
}
|
|
|
|
// get — общая выборка одного устройства.
|
|
func (s *DeviceStore) get(ctx context.Context, query string, args ...any) (*Device, error) {
|
|
d, err := scanDevice(s.db.QueryRowContext(ctx, query, args...))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: get device: %w", err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// UpdateEnabled включает/выключает устройство.
|
|
func (s *DeviceStore) UpdateEnabled(ctx context.Context, namespace, name string, enabled bool) error {
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE iot_devices SET enabled = $3, phase = $4
|
|
WHERE namespace = $1 AND name = $2`,
|
|
namespace, name, enabled, phaseOf(enabled))
|
|
if err != nil {
|
|
return fmt.Errorf("store: update device: %w", err)
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete удаляет устройство.
|
|
func (s *DeviceStore) Delete(ctx context.Context, namespace, name string) error {
|
|
res, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM iot_devices WHERE namespace = $1 AND name = $2`, namespace, name)
|
|
if err != nil {
|
|
return fmt.Errorf("store: delete device: %w", err)
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TouchLastConnected обновляет last_connected (best-effort при MQTT auth).
|
|
func (s *DeviceStore) TouchLastConnected(ctx context.Context, namespace, deviceID string) {
|
|
_, _ = s.db.ExecContext(ctx, `
|
|
UPDATE iot_devices SET last_connected = now()
|
|
WHERE namespace = $1 AND device_id = $2`, namespace, deviceID)
|
|
}
|
|
|
|
// Count возвращает количество устройств в namespace (для админ-статистики).
|
|
func (s *DeviceStore) Count(ctx context.Context, namespace string) (int64, error) {
|
|
var n int64
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM iot_devices WHERE namespace = $1`, namespace).Scan(&n)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("store: count devices: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// scanDevices читает все строки результата.
|
|
func scanDevices(rows *sql.Rows) ([]Device, error) {
|
|
var out []Device
|
|
for rows.Next() {
|
|
d, err := scanDevice(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// scanDevice читает одну строку устройства.
|
|
type rowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanDevice(r rowScanner) (*Device, error) {
|
|
var (
|
|
d Device
|
|
metadata []byte
|
|
lastConn sql.NullTime
|
|
)
|
|
err := r.Scan(&d.Namespace, &d.Name, &d.DeviceID, &d.Enabled, &d.MQTTPassword,
|
|
&metadata, &d.Phase, &lastConn, &d.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(metadata) > 0 {
|
|
_ = json.Unmarshal(metadata, &d.Metadata)
|
|
}
|
|
if lastConn.Valid {
|
|
t := lastConn.Time
|
|
d.LastConnected = &t
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
// phaseOf — статус устройства в зависимости от enabled.
|
|
func phaseOf(enabled bool) string {
|
|
if enabled {
|
|
return "Active"
|
|
}
|
|
return "Disabled"
|
|
}
|
|
|
|
// isUniqueViolation определяет нарушение уникальности PostgreSQL.
|
|
func isUniqueViolation(err error) bool {
|
|
return err != nil && err.Error() != "" && hasSQLState(err, "23505")
|
|
}
|
|
|
|
func hasSQLState(err error, state string) bool {
|
|
type stater interface{ SQLState() string }
|
|
var e stater
|
|
return errors.As(err, &e) && e.SQLState() == state
|
|
}
|