// Package manualsync synchronizes measurement manual-mode changes with the // protocol service responsible for the measurement's data source package manualsync import ( "bytes" "context" "encoding/json" "fmt" "io" "math" "net/http" "net/url" "strconv" "strings" "modelRT/config" "modelRT/constants" "modelRT/orm" ) const maxErrorResponseBody = 4 << 10 // SyntheticData is one manually supplied measurement value type SyntheticData struct { Time int64 `json:"time"` Value float64 `json:"value"` } // Target identifies a measurement in a downstream protocol service type Target struct { Type int `json:"type"` Station string `json:"station"` MainPos string `json:"main_pos"` SubPos string `json:"sub_pos"` Option string `json:"option"` } // Request is the payload accepted by POST /api/manual type Request struct { Mode int16 `json:"mode"` Data []SyntheticData `json:"data,omitempty"` Target Target `json:"target"` } // Syncer synchronizes a measurement mode or manual-value change type Syncer interface { Sync(context.Context, orm.JSONMap, int16, *SyntheticData) error } // Client calls the protocol-specific manual synchronization endpoint type Client struct { httpClient *http.Client protocolCL3611URL string protocol104URL string } // NewClient validates the configuration and constructs a reusable client func NewClient(cfg config.ManualSyncConfig) (*Client, error) { if cfg.Timeout <= 0 { return nil, fmt.Errorf("manual sync timeout must be greater than zero") } protocolCL3611URL, err := endpointURL(cfg.ProtocolCL3611URL, cfg.APIPath) if err != nil { return nil, fmt.Errorf("invalid protocol CL3611 URL: %w", err) } protocol104URL, err := endpointURL(cfg.Protocol104URL, cfg.APIPath) if err != nil { return nil, fmt.Errorf("invalid protocol 104 URL: %w", err) } return &Client{ httpClient: &http.Client{Timeout: cfg.Timeout}, protocolCL3611URL: protocolCL3611URL, protocol104URL: protocol104URL, }, nil } // Sync posts one mode transition or manual-value update. Data is omitted for // mode transitions and included only when sample is non-nil in manual mode func (c *Client) Sync(ctx context.Context, dataSource orm.JSONMap, mode int16, data *SyntheticData) error { if c == nil || c.httpClient == nil { return fmt.Errorf("manual sync client is not initialized") } if mode != constants.MeasurementModeManual && mode != constants.MeasurementModeAutomatic { return fmt.Errorf("manual sync mode must be 0 or 1, got %d", mode) } if mode == constants.MeasurementModeAutomatic && data != nil { return fmt.Errorf("automatic mode manual sync request cannot contain data") } endpoint, target, err := c.resolveTarget(dataSource) if err != nil { return err } requestPayload := Request{Mode: mode, Target: target} if data != nil { requestPayload.Data = []SyntheticData{*data} } body, err := json.Marshal(requestPayload) if err != nil { return fmt.Errorf("encode manual sync request: %w", err) } request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return fmt.Errorf("create manual sync request: %w", err) } request.Header.Set("Content-Type", "application/json") response, err := c.httpClient.Do(request) if err != nil { return fmt.Errorf("call manual sync endpoint: %w", err) } defer response.Body.Close() if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices { _, _ = io.Copy(io.Discard, response.Body) return nil } responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxErrorResponseBody)) if readErr != nil { return fmt.Errorf("manual sync endpoint returned %s and response body could not be read: %w", response.Status, readErr) } message := strings.TrimSpace(string(responseBody)) if message == "" { return fmt.Errorf("manual sync endpoint returned %s", response.Status) } return fmt.Errorf("manual sync endpoint returned %s: %s", response.Status, message) } type rawDataSource struct { Type int `json:"type"` IOAddress rawIOAddress `json:"io_address"` } type rawIOAddress struct { DType int `json:"dtype"` Station string `json:"station"` Device string `json:"device"` Channel string `json:"channel"` Option string `json:"option"` Packet any `json:"packet"` Offset any `json:"offset"` } func (c *Client) resolveTarget(dataSource orm.JSONMap) (string, Target, error) { if dataSource == nil { return "", Target{}, fmt.Errorf("measurement data_source is null") } encoded, err := json.Marshal(dataSource) if err != nil { return "", Target{}, fmt.Errorf("encode measurement data_source: %w", err) } var source rawDataSource if err := json.Unmarshal(encoded, &source); err != nil { return "", Target{}, fmt.Errorf("decode measurement data_source: %w", err) } station := strings.TrimSpace(source.IOAddress.Station) if station == "" { return "", Target{}, fmt.Errorf("measurement data_source io_address.station is required") } switch source.Type { case 1: device := strings.TrimSpace(source.IOAddress.Device) channel := strings.TrimSpace(source.IOAddress.Channel) if device == "" { return "", Target{}, fmt.Errorf("CL3611 data_source io_address.device is required") } if channel == "" { return "", Target{}, fmt.Errorf("CL3611 data_source io_address.channel is required") } target := Target{ Station: station, MainPos: device, SubPos: channel, } switch source.IOAddress.DType { case 1: target.Type = 1 target.Option = strings.TrimSpace(source.IOAddress.Option) case 2: target.Type = 2 default: return "", Target{}, fmt.Errorf("CL3611 data_source dtype must be 1 or 2, got %d", source.IOAddress.DType) } return c.protocolCL3611URL, target, nil case 2: packet, err := integerString(source.IOAddress.Packet) if err != nil { return "", Target{}, fmt.Errorf("104 data_source io_address.packet: %w", err) } offset, err := integerString(source.IOAddress.Offset) if err != nil { return "", Target{}, fmt.Errorf("104 data_source io_address.offset: %w", err) } return c.protocol104URL, Target{ Type: 3, Station: station, MainPos: packet, SubPos: offset, Option: "", }, nil default: return "", Target{}, fmt.Errorf("unsupported measurement data_source type %d", source.Type) } } func endpointURL(baseURL, apiPath string) (string, error) { baseURL = strings.TrimSpace(baseURL) if baseURL == "" { return "", fmt.Errorf("base URL is required") } apiPath = strings.TrimSpace(apiPath) if apiPath == "" { return "", fmt.Errorf("API path is required") } endpoint := strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(apiPath, "/") parsed, err := url.Parse(endpoint) if err != nil { return "", err } if parsed.Scheme != "http" && parsed.Scheme != "https" { return "", fmt.Errorf("URL scheme must be http or https") } if parsed.Host == "" { return "", fmt.Errorf("URL host is required") } return parsed.String(), nil } func integerString(value any) (string, error) { switch typed := value.(type) { case nil: return "", fmt.Errorf("is required") case float64: if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed { return "", fmt.Errorf("must be an integer") } return strconv.FormatInt(int64(typed), 10), nil case string: trimmed := strings.TrimSpace(typed) if trimmed == "" { return "", fmt.Errorf("is required") } integer, err := strconv.ParseInt(trimmed, 10, 64) if err != nil { return "", fmt.Errorf("must be an integer: %w", err) } return strconv.FormatInt(integer, 10), nil default: return "", fmt.Errorf("has unsupported type %T", value) } }