Add realtime gateway and simulator bridge
This commit is contained in:
48
realtime-gateway/cmd/gateway/main.go
Normal file
48
realtime-gateway/cmd/gateway/main.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"realtime-gateway/internal/config"
|
||||
"realtime-gateway/internal/gateway"
|
||||
"realtime-gateway/internal/logging"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "./config/dev.json", "path to config file")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
logger := logging.New()
|
||||
app, err := gateway.NewServer(cfg, logger)
|
||||
if err != nil {
|
||||
logger.Error("failed to create server", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- app.Run(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("shutdown signal received")
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
logger.Error("server stopped with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
354
realtime-gateway/cmd/mock-consumer/main.go
Normal file
354
realtime-gateway/cmd/mock-consumer/main.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"realtime-gateway/internal/model"
|
||||
)
|
||||
|
||||
type consumerConfig struct {
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token"`
|
||||
ChannelID string `json:"channelId"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
GroupID string `json:"groupId"`
|
||||
Topic string `json:"topic"`
|
||||
Topics []string `json:"topics"`
|
||||
Subscriptions []model.Subscription `json:"subscriptions"`
|
||||
Snapshot *bool `json:"snapshot"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath := findConfigPath(os.Args[1:])
|
||||
fileConfig, err := loadConsumerConfig(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
flag.StringVar(&configPath, "config", configPath, "path to consumer config file")
|
||||
url := flag.String("url", valueOr(fileConfig.URL, "ws://127.0.0.1:8080/ws"), "gateway websocket url")
|
||||
token := flag.String("token", fileConfig.Token, "consumer token, leave empty if anonymous consumers are allowed")
|
||||
channelID := flag.String("channel-id", fileConfig.ChannelID, "channel id to join before subscribe")
|
||||
deviceID := flag.String("device-id", valueOr(fileConfig.DeviceID, "child-001"), "device id to subscribe")
|
||||
groupID := flag.String("group-id", fileConfig.GroupID, "group id to subscribe")
|
||||
topic := flag.String("topic", valueOr(fileConfig.Topic, "telemetry.location"), "single topic to subscribe")
|
||||
topics := flag.String("topics", strings.Join(fileConfig.Topics, ","), "comma-separated topics to subscribe, overrides -topic when set")
|
||||
snapshot := flag.Bool("snapshot", boolValue(fileConfig.Snapshot, true), "request latest snapshot after subscribe")
|
||||
flag.Parse()
|
||||
|
||||
subscriptions := resolveSubscriptions(fileConfig, *deviceID, *groupID, *topic, *topics)
|
||||
if len(subscriptions) == 0 {
|
||||
log.Fatalf("no subscriptions configured")
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
conn, _, err := websocket.Dial(ctx, *url, nil)
|
||||
if err != nil {
|
||||
log.Fatalf("dial gateway: %v", err)
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "consumer closed")
|
||||
|
||||
var welcome model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &welcome); err != nil {
|
||||
log.Fatalf("read welcome: %v", err)
|
||||
}
|
||||
log.Printf("connected: session=%s", welcome.SessionID)
|
||||
|
||||
if *token != "" || *channelID != "" {
|
||||
authReq := model.ClientMessage{
|
||||
Type: "authenticate",
|
||||
Role: model.RoleConsumer,
|
||||
Token: *token,
|
||||
}
|
||||
if *channelID != "" {
|
||||
authReq.Type = "join_channel"
|
||||
authReq.ChannelID = *channelID
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, authReq); err != nil {
|
||||
log.Fatalf("send authenticate: %v", err)
|
||||
}
|
||||
var authResp model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &authResp); err != nil {
|
||||
log.Fatalf("read authenticate response: %v", err)
|
||||
}
|
||||
if authResp.Type == "error" {
|
||||
log.Fatalf("authenticate failed: %s", authResp.Error)
|
||||
}
|
||||
log.Printf("authenticated: session=%s", authResp.SessionID)
|
||||
}
|
||||
|
||||
subReq := model.ClientMessage{
|
||||
Type: "subscribe",
|
||||
Subscriptions: subscriptions,
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, subReq); err != nil {
|
||||
log.Fatalf("send subscribe: %v", err)
|
||||
}
|
||||
|
||||
var subResp model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &subResp); err != nil {
|
||||
log.Fatalf("read subscribe response: %v", err)
|
||||
}
|
||||
if subResp.Type == "error" {
|
||||
log.Fatalf("subscribe failed: %s", subResp.Error)
|
||||
}
|
||||
log.Printf("subscribed: %s", describeSubscriptions(subscriptions))
|
||||
|
||||
if *snapshot {
|
||||
req := model.ClientMessage{
|
||||
Type: "snapshot",
|
||||
Subscriptions: []model.Subscription{
|
||||
{DeviceID: firstSnapshotDeviceID(subscriptions, *deviceID)},
|
||||
},
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, req); err != nil {
|
||||
log.Fatalf("send snapshot: %v", err)
|
||||
}
|
||||
var resp model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &resp); err != nil {
|
||||
log.Fatalf("read snapshot response: %v", err)
|
||||
}
|
||||
if resp.Type == "snapshot" {
|
||||
log.Printf("snapshot: %s", compactJSON(resp.State))
|
||||
} else if resp.Type == "error" {
|
||||
log.Printf("snapshot unavailable: %s", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
var message model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &message); err != nil {
|
||||
log.Fatalf("read event: %v", err)
|
||||
}
|
||||
switch message.Type {
|
||||
case "event":
|
||||
log.Printf("event: %s", describeEnvelope(message.Envelope))
|
||||
case "error":
|
||||
log.Printf("server error: %s", message.Error)
|
||||
default:
|
||||
log.Printf("message: type=%s", message.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadConsumerConfig(path string) (consumerConfig, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return consumerConfig{}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return consumerConfig{}, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg consumerConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return consumerConfig{}, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func findConfigPath(args []string) string {
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
if strings.HasPrefix(arg, "-config=") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "-config="))
|
||||
}
|
||||
if arg == "-config" && index+1 < len(args) {
|
||||
return strings.TrimSpace(args[index+1])
|
||||
}
|
||||
if strings.HasPrefix(arg, "--config=") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--config="))
|
||||
}
|
||||
if arg == "--config" && index+1 < len(args) {
|
||||
return strings.TrimSpace(args[index+1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func valueOr(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func boolValue(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func resolveSubscriptions(fileConfig consumerConfig, deviceID string, groupID string, topic string, topicsCSV string) []model.Subscription {
|
||||
if len(fileConfig.Subscriptions) > 0 && strings.TrimSpace(topicsCSV) == "" && strings.TrimSpace(topic) == valueOr(fileConfig.Topic, "telemetry.location") {
|
||||
return filterValidSubscriptions(fileConfig.Subscriptions)
|
||||
}
|
||||
|
||||
topics := parseTopics(topicsCSV)
|
||||
if len(topics) == 0 {
|
||||
topics = []string{topic}
|
||||
}
|
||||
|
||||
subscriptions := make([]model.Subscription, 0, len(topics))
|
||||
for _, entry := range topics {
|
||||
subscriptions = append(subscriptions, model.Subscription{
|
||||
DeviceID: deviceID,
|
||||
GroupID: groupID,
|
||||
Topic: entry,
|
||||
})
|
||||
}
|
||||
return filterValidSubscriptions(subscriptions)
|
||||
}
|
||||
|
||||
func parseTopics(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
topics := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
topic := strings.TrimSpace(part)
|
||||
if topic == "" {
|
||||
continue
|
||||
}
|
||||
topics = append(topics, topic)
|
||||
}
|
||||
return topics
|
||||
}
|
||||
|
||||
func filterValidSubscriptions(items []model.Subscription) []model.Subscription {
|
||||
subscriptions := make([]model.Subscription, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item.ChannelID) == "" && strings.TrimSpace(item.DeviceID) == "" && strings.TrimSpace(item.GroupID) == "" {
|
||||
continue
|
||||
}
|
||||
subscriptions = append(subscriptions, model.Subscription{
|
||||
ChannelID: strings.TrimSpace(item.ChannelID),
|
||||
DeviceID: strings.TrimSpace(item.DeviceID),
|
||||
GroupID: strings.TrimSpace(item.GroupID),
|
||||
Topic: strings.TrimSpace(item.Topic),
|
||||
})
|
||||
}
|
||||
return subscriptions
|
||||
}
|
||||
|
||||
func describeSubscriptions(items []model.Subscription) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
scope := item.DeviceID
|
||||
if scope == "" {
|
||||
if item.GroupID != "" {
|
||||
scope = "group:" + item.GroupID
|
||||
} else {
|
||||
scope = "all"
|
||||
}
|
||||
}
|
||||
if item.ChannelID != "" {
|
||||
scope = "channel:" + item.ChannelID + " / " + scope
|
||||
}
|
||||
if item.Topic != "" {
|
||||
scope += "/" + item.Topic
|
||||
}
|
||||
parts = append(parts, scope)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func firstSnapshotDeviceID(items []model.Subscription, fallback string) string {
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item.DeviceID) != "" {
|
||||
return item.DeviceID
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func describeEnvelope(envelope *model.Envelope) string {
|
||||
if envelope == nil {
|
||||
return "{}"
|
||||
}
|
||||
switch envelope.Topic {
|
||||
case "telemetry.location":
|
||||
return describeLocationEnvelope(envelope)
|
||||
case "telemetry.heart_rate":
|
||||
return describeHeartRateEnvelope(envelope)
|
||||
default:
|
||||
return compactEnvelope(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func describeLocationEnvelope(envelope *model.Envelope) string {
|
||||
var payload struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Speed float64 `json:"speed"`
|
||||
Bearing float64 `json:"bearing"`
|
||||
Accuracy float64 `json:"accuracy"`
|
||||
CoordSystem string `json:"coordSystem"`
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return compactEnvelope(envelope)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"gps device=%s lat=%.6f lng=%.6f speed=%.2f bearing=%.1f accuracy=%.1f coord=%s",
|
||||
envelope.Target.DeviceID,
|
||||
payload.Lat,
|
||||
payload.Lng,
|
||||
payload.Speed,
|
||||
payload.Bearing,
|
||||
payload.Accuracy,
|
||||
payload.CoordSystem,
|
||||
)
|
||||
}
|
||||
|
||||
func describeHeartRateEnvelope(envelope *model.Envelope) string {
|
||||
var payload struct {
|
||||
BPM int `json:"bpm"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return compactEnvelope(envelope)
|
||||
}
|
||||
if payload.Confidence > 0 {
|
||||
return fmt.Sprintf("heart_rate device=%s bpm=%d confidence=%.2f", envelope.Target.DeviceID, payload.BPM, payload.Confidence)
|
||||
}
|
||||
return fmt.Sprintf("heart_rate device=%s bpm=%d", envelope.Target.DeviceID, payload.BPM)
|
||||
}
|
||||
|
||||
func compactEnvelope(envelope *model.Envelope) string {
|
||||
if envelope == nil {
|
||||
return "{}"
|
||||
}
|
||||
data, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return compactJSON(data)
|
||||
}
|
||||
|
||||
func compactJSON(data []byte) string {
|
||||
var value any
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return string(data)
|
||||
}
|
||||
compact, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return string(data)
|
||||
}
|
||||
return string(compact)
|
||||
}
|
||||
190
realtime-gateway/cmd/mock-producer/main.go
Normal file
190
realtime-gateway/cmd/mock-producer/main.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"realtime-gateway/internal/model"
|
||||
)
|
||||
|
||||
func main() {
|
||||
url := flag.String("url", "ws://127.0.0.1:8080/ws", "gateway websocket url")
|
||||
token := flag.String("token", "dev-producer-token", "producer token")
|
||||
channelID := flag.String("channel-id", "", "channel id to join before publish")
|
||||
deviceID := flag.String("device-id", "child-001", "target device id")
|
||||
groupID := flag.String("group-id", "", "target group id")
|
||||
sourceID := flag.String("source-id", "mock-producer-001", "source id")
|
||||
mode := flag.String("mode", "mock", "source mode")
|
||||
topic := flag.String("topic", "telemetry.location", "publish topic")
|
||||
lat := flag.Float64("lat", 31.2304, "start latitude")
|
||||
lng := flag.Float64("lng", 121.4737, "start longitude")
|
||||
speed := flag.Float64("speed", 1.2, "speed value")
|
||||
accuracy := flag.Float64("accuracy", 6, "accuracy value")
|
||||
bpm := flag.Int("bpm", 120, "heart rate value when topic is telemetry.heart_rate")
|
||||
confidence := flag.Float64("confidence", 0, "heart rate confidence when topic is telemetry.heart_rate")
|
||||
interval := flag.Duration("interval", time.Second, "publish interval")
|
||||
count := flag.Int("count", 0, "number of messages to send, 0 means unlimited")
|
||||
flag.Parse()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
conn, _, err := websocket.Dial(ctx, *url, nil)
|
||||
if err != nil {
|
||||
log.Fatalf("dial gateway: %v", err)
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "producer closed")
|
||||
|
||||
var welcome model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &welcome); err != nil {
|
||||
log.Fatalf("read welcome: %v", err)
|
||||
}
|
||||
log.Printf("connected: session=%s", welcome.SessionID)
|
||||
|
||||
authReq := model.ClientMessage{
|
||||
Type: "authenticate",
|
||||
Role: model.RoleProducer,
|
||||
Token: *token,
|
||||
}
|
||||
if *channelID != "" {
|
||||
authReq.Type = "join_channel"
|
||||
authReq.ChannelID = *channelID
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, authReq); err != nil {
|
||||
log.Fatalf("send authenticate: %v", err)
|
||||
}
|
||||
|
||||
var authResp model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &authResp); err != nil {
|
||||
log.Fatalf("read authenticate response: %v", err)
|
||||
}
|
||||
if authResp.Type == "error" {
|
||||
log.Fatalf("authenticate failed: %s", authResp.Error)
|
||||
}
|
||||
log.Printf("authenticated: session=%s", authResp.SessionID)
|
||||
|
||||
ticker := time.NewTicker(*interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
sent := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("producer stopping")
|
||||
return
|
||||
case <-ticker.C:
|
||||
envelope := model.Envelope{
|
||||
SchemaVersion: 1,
|
||||
MessageID: messageID(sent + 1),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Topic: *topic,
|
||||
Source: model.Source{
|
||||
Kind: model.RoleProducer,
|
||||
ID: *sourceID,
|
||||
Mode: *mode,
|
||||
},
|
||||
Target: model.Target{
|
||||
DeviceID: *deviceID,
|
||||
GroupID: *groupID,
|
||||
},
|
||||
Payload: payloadForTopic(*topic, *lat, *lng, *speed, *accuracy, *bpm, *confidence, sent),
|
||||
}
|
||||
|
||||
req := model.ClientMessage{
|
||||
Type: "publish",
|
||||
Envelope: &envelope,
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, req); err != nil {
|
||||
log.Fatalf("publish failed: %v", err)
|
||||
}
|
||||
|
||||
var resp model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &resp); err != nil {
|
||||
log.Fatalf("read publish response: %v", err)
|
||||
}
|
||||
if resp.Type == "error" {
|
||||
log.Fatalf("publish rejected: %s", resp.Error)
|
||||
}
|
||||
|
||||
sent++
|
||||
log.Printf("published #%d %s", sent, describePublished(*topic, *deviceID, *lat, *lng, *speed, *accuracy, *bpm, *confidence, sent))
|
||||
if *count > 0 && sent >= *count {
|
||||
log.Println("producer completed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func payloadForTopic(topic string, baseLat, baseLng, speed, accuracy float64, bpm int, confidence float64, step int) []byte {
|
||||
if topic == "telemetry.heart_rate" {
|
||||
return heartRatePayload(bpm, confidence)
|
||||
}
|
||||
return locationPayload(baseLat, baseLng, speed, accuracy, step)
|
||||
}
|
||||
|
||||
func locationPayload(baseLat, baseLng, speed, accuracy float64, step int) []byte {
|
||||
lat := currentLat(baseLat, step)
|
||||
lng := currentLng(baseLng, step)
|
||||
return []byte(
|
||||
`{"lat":` + formatFloat(lat) +
|
||||
`,"lng":` + formatFloat(lng) +
|
||||
`,"speed":` + formatFloat(speed) +
|
||||
`,"bearing":90` +
|
||||
`,"accuracy":` + formatFloat(accuracy) +
|
||||
`,"coordSystem":"GCJ02"}`,
|
||||
)
|
||||
}
|
||||
|
||||
func heartRatePayload(bpm int, confidence float64) []byte {
|
||||
if confidence > 0 {
|
||||
return []byte(
|
||||
`{"bpm":` + strconv.Itoa(maxInt(1, bpm)) +
|
||||
`,"confidence":` + formatFloat(confidence) +
|
||||
`}`,
|
||||
)
|
||||
}
|
||||
return []byte(`{"bpm":` + strconv.Itoa(maxInt(1, bpm)) + `}`)
|
||||
}
|
||||
|
||||
func currentLat(base float64, step int) float64 {
|
||||
return base + float64(step)*0.00002
|
||||
}
|
||||
|
||||
func currentLng(base float64, step int) float64 {
|
||||
return base + float64(step)*0.00003
|
||||
}
|
||||
|
||||
func messageID(step int) string {
|
||||
return "msg-" + strconv.Itoa(step)
|
||||
}
|
||||
|
||||
func formatFloat(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', 6, 64)
|
||||
}
|
||||
|
||||
func describePublished(topic string, deviceID string, lat, lng, speed, accuracy float64, bpm int, confidence float64, step int) string {
|
||||
if topic == "telemetry.heart_rate" {
|
||||
if confidence > 0 {
|
||||
return "device=" + deviceID + " topic=" + topic + " bpm=" + strconv.Itoa(maxInt(1, bpm)) + " confidence=" + formatFloat(confidence)
|
||||
}
|
||||
return "device=" + deviceID + " topic=" + topic + " bpm=" + strconv.Itoa(maxInt(1, bpm))
|
||||
}
|
||||
return "device=" + deviceID + " topic=" + topic + " lat=" + formatFloat(currentLat(lat, step)) + " lng=" + formatFloat(currentLng(lng, step)) + " speed=" + formatFloat(speed) + " accuracy=" + formatFloat(accuracy)
|
||||
}
|
||||
|
||||
func maxInt(left int, right int) int {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
Reference in New Issue
Block a user