Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
60f7804
fix(electron): added missing file from previous commit for electron i…
l-castel Jun 19, 2026
caa6a40
fix(competition-view): fixed bug found in the telemetry update
l-castel Jun 24, 2026
22accea
feat(competition-view): created unified dashoard instead of separate …
l-castel Jun 24, 2026
180186b
feat(competition-view): implemented cell visualization in battery man…
l-castel Jun 25, 2026
8c44775
feat(competition-view): new design, new values, correct handling of m…
l-castel Jul 8, 2026
0a4a9f0
feat(competition-view): removed lvbms and blcu, put orders into dashb…
l-castel Jul 9, 2026
27c18e5
feat(competition-view): changes made at ehw
l-castel Jul 13, 2026
687dacc
feat: changes
JavierRibaldelRio Jul 14, 2026
323f242
feat: enhance batteries
JavierRibaldelRio Jul 14, 2026
38917b1
feat(competition-view): add light indicator rule 165
JavierRibaldelRio Jul 14, 2026
f044ceb
feat(competition-view): changed graph behaviour, changed vehicle stat…
l-castel Jul 14, 2026
74d52b3
feat(competition-view): changed orders panel, changed track icon, rem…
l-castel Jul 14, 2026
46a0e7f
fix(competition-view): remove HVAL light
JavierRibaldelRio Jul 14, 2026
55bd8ec
fix(competition view): icons
JavierRibaldelRio Jul 14, 2026
7d7ad35
feat(competition-view): about section
JavierRibaldelRio Jul 14, 2026
bc9e879
fix(competition-view): move shortcut helper
JavierRibaldelRio Jul 14, 2026
1b8a52c
fix(competition-view): enhance topbar
JavierRibaldelRio Jul 15, 2026
2208019
fix(competition-view): Maxim's yellow
JavierRibaldelRio Jul 15, 2026
e1313b8
wip(competition-view): safety range
JavierRibaldelRio Jul 15, 2026
6ceaed9
feat(competition-view): logger view
JavierRibaldelRio Jul 15, 2026
d35437f
fix(competition-view): fixed message timestamp
l-castel Jul 15, 2026
61d8f52
Merge branch 'develop' into features/competition-view-redesign
l-castel Jul 15, 2026
ee9992f
fix(connection): send fault if frontend disconnect
JavierRibaldelRio Jul 15, 2026
467dbbf
feat(competition-view): side panel with full orders functionality
l-castel Jul 15, 2026
1e97ada
fix(competition-view): add parametrized orders into order side panel
l-castel Jul 15, 2026
f1bf1e5
fix(competition-view): add interval errors
JavierRibaldelRio Jul 16, 2026
de0d0c6
feat():sdf
JavierRibaldelRio Jul 16, 2026
5371a0c
Merge branch 'develop' into features/competition-view-redesign
JavierRibaldelRio Jul 16, 2026
16abed2
fix(competition-view): modify safe ranges
JavierRibaldelRio Jul 16, 2026
d3af87b
fix(competition-view): change range
JavierRibaldelRio Jul 16, 2026
efcc827
fix(backend): update dev-config to match new keep alive
JavierRibaldelRio Jul 16, 2026
6e3b165
Merge pull request #575 from Hyperloop-UPV/features/competition-view-…
JavierRibaldelRio Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/cmd/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ backoff_min_ms = 100 # Minimum backoff duration in milliseconds
backoff_max_ms = 5000 # Maximum backoff duration in milliseconds
backoff_multiplier = 1.5 # Exponential backoff multiplier (e.g., 1.5 means each retry waits 1.5x longer)
max_retries = 0 # Maximum retries before cycling (0 = infinite retries, recommended for persistent reconnection)
connection_timeout_ms = 3000 # Connection timeout in milliseconds
keep_alive_ms = 1000 # Keep-alive interval in milliseconds
keep_alive_interval_ms = 50


# UDP Configuration
# These settings control the UDP server's buffer sizes and performance characteristics
Expand Down
2 changes: 1 addition & 1 deletion backend/cmd/dev-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ backoff_max_ms = 999999 # Maximum backoff duration in milliseconds
backoff_multiplier = 1 # Exponential backoff multiplier (e.g., 1.5 means each retry waits 1.5x longer)
max_retries = 0 # Maximum retries before cycling (0 = infinite retries, recommended for persistent reconnection)
connection_timeout_ms = 999999 # Timeout for the initial connection attempt
keep_alive_ms = 0 # Keep-alive interval in milliseconds (0 to disable)
keep_alive_interval_ms = 0 # Keep-alive interval in milliseconds (0 to disable)

# UDP Configuration
# These settings control the UDP server's buffer sizes and performance characteristics
Expand Down
27 changes: 13 additions & 14 deletions backend/cmd/setup_transport.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"context"
"encoding/binary"
"fmt"
"net"
Expand Down Expand Up @@ -52,6 +51,8 @@ func configureTransport(
// Start handling network packets using UDP server
configureUDPServerTransport(adj, transp, config)

// Start the application-level TCP keep-alive (empty order with id 1)
go transp.HandleKeepAlive(time.Duration(config.TCP.KeepAliveIntervalMs) * time.Millisecond)
}

func configureTCPClientTransport(
Expand Down Expand Up @@ -86,11 +87,6 @@ func configureTCPClientTransport(
clientConfig.Timeout = time.Duration(config.TCP.ConnectionTimeout) * time.Millisecond
}

// Apply custom keep-alive if specified
if config.TCP.KeepAlive > 0 {
clientConfig.KeepAlive = time.Duration(config.TCP.KeepAlive) * time.Millisecond
}

// Apply custom backoff parameters
if config.TCP.BackoffMinMs > 0 || config.TCP.BackoffMaxMs > 0 || config.TCP.BackoffMultiplier > 0 {
minBackoff := 100 * time.Millisecond // default
Expand Down Expand Up @@ -118,18 +114,12 @@ func configureTCPClientTransport(
}
}

// configureTCPServerTransport starts the TCP server handler using a ListenConfig with KeepAlive.
// configureTCPServerTransport starts the TCP server handler.
func configureTCPServerTransport(
adj adj_module.ADJ,
transp *transport.Transport,
) {
go transp.HandleServer(tcp.ServerConfig{
ListenConfig: net.ListenConfig{
KeepAlive: time.Second,
},
Context: context.TODO(),
}, fmt.Sprintf("%s:%d", adj.Info.Addresses[BACKEND], adj.Info.Ports[TcpServer]))

go transp.HandleServer(tcp.NewServerConfig(), fmt.Sprintf("%s:%d", adj.Info.Addresses[BACKEND], adj.Info.Ports[TcpServer]))
}

// configureUDPServerTransport creates and starts the UDP server then delegates handling to transport.
Expand Down Expand Up @@ -246,6 +236,15 @@ func getTransportDecEnc(info adj_module.Info, podData pod_data.PodData) (*presen
encoder.SetPacketEncoder(id, dataEncoder)
}

// The TCP keep-alive order is an empty packet, so give it an empty
// descriptor unless the ADJ already defines packet id 1
if !common.Contains(ids, transport.KeepAliveId) {
dataDecoder.SetDescriptor(transport.KeepAliveId, data.Descriptor{})
dataEncoder.SetDescriptor(transport.KeepAliveId, data.Descriptor{})
decoder.SetPacketDecoder(transport.KeepAliveId, dataDecoder)
encoder.SetPacketEncoder(transport.KeepAliveId, dataEncoder)
}

// TODO Solve this foking mess, I have tried...
stateOrdersDecoder := order.NewDecoder(binary.LittleEndian)
stateOrdersDecoder.SetActionId(abstraction.PacketId(info.MessageIds[AddStateOrder]), stateOrdersDecoder.DecodeAdd)
Expand Down
9 changes: 9 additions & 0 deletions backend/cmd/setup_vehicle.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ func configureBroker(subloggers abstraction.SubloggersMap, loggerHandler *logger
pool := websocket.NewPool(connections, trace.Logger)
pool.SetOnDisconnect(func(count int) {
if count == 0 {
// Losing the last control-station GUI (closed, crashed or hung:
// heartbeats stop and the read deadline expires) must put the
// vehicle in a safe state, as required by competition rules.
trace.Warn().Msg("no clients connected, sending FAULT order")
faultOrder := &order_topic.Order{Id: 0, Fields: map[string]order_topic.Field{}}
if err := broker.UserPush(faultOrder); err != nil {
trace.Error().Err(err).Msg("failed to send fault order on client disconnect")
}

trace.Info().Msg("no clients connected, stopping logger")
loggerHandler.Stop()
if err := loggerTopic.NotifyStopped(); err != nil {
Expand Down
12 changes: 6 additions & 6 deletions backend/internal/config/config_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ type Transport struct {
}

type TCP struct {
BackoffMinMs int `toml:"backoff_min_ms"`
BackoffMaxMs int `toml:"backoff_max_ms"`
BackoffMultiplier float64 `toml:"backoff_multiplier"`
MaxRetries int `toml:"max_retries"`
ConnectionTimeout int `toml:"connection_timeout_ms"`
KeepAlive int `toml:"keep_alive_ms"`
BackoffMinMs int `toml:"backoff_min_ms"`
BackoffMaxMs int `toml:"backoff_max_ms"`
BackoffMultiplier float64 `toml:"backoff_multiplier"`
MaxRetries int `toml:"max_retries"`
ConnectionTimeout int `toml:"connection_timeout_ms"`
KeepAliveIntervalMs int `toml:"keep_alive_interval_ms"`
}

type UDP struct {
Expand Down
72 changes: 72 additions & 0 deletions backend/pkg/transport/keepalive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package transport

import (
"errors"
"fmt"
"net"
"time"

"github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction"
"github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data"
)

// KeepAliveId is the packet id of the empty order used as the
// application-level TCP keep-alive.
const KeepAliveId abstraction.PacketId = 1

// DefaultKeepAliveInterval is how often the keep-alive order is sent to each
// board when no interval is configured.
const DefaultKeepAliveInterval = 50 * time.Millisecond

// HandleKeepAlive broadcasts an empty order with id KeepAliveId to every
// connected board every interval. Write errors are forwarded to the
// connection handler by the connection wrapper, which tears the connection
// down. This method blocks.
func (transport *Transport) HandleKeepAlive(interval time.Duration) {
if interval <= 0 {
interval = DefaultKeepAliveInterval
}
// The keep-alive frame never changes, so encode it once up front
buf, err := transport.encoder.Encode(data.NewPacket(KeepAliveId))
if err != nil {
transport.logger.Error().Stack().Err(err).Msg("encode keep-alive")
transport.errChan <- err
return
}
frame := make([]byte, buf.Len())
copy(frame, buf.Bytes())
transport.encoder.ReleaseBuffer(buf)

ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
transport.broadcastKeepAlive(frame, interval)
}
}

// broadcastKeepAlive writes the keep-alive frame to all active connections.
func (transport *Transport) broadcastKeepAlive(frame []byte, interval time.Duration) {
transport.connectionsMx.RLock()
defer transport.connectionsMx.RUnlock()

for target, conn := range transport.connections {
// Bound the write so a dead peer with a full send buffer cannot hold
// the connections lock past the next tick
conn.SetWriteDeadline(time.Now().Add(interval))
totalWritten := 0
for totalWritten < len(frame) {
n, err := conn.Write(frame[totalWritten:])
totalWritten += n
if err != nil {
// net.ErrClosed means the connection was closed on purpose
// and its handler already reported the reason
if !errors.Is(err, net.ErrClosed) {
transport.logger.Error().Stack().Err(err).Str("target", string(target)).Msg("keep-alive write")
transport.errChan <- fmt.Errorf("TCP keep-alive to board %s failed: %w", target, err)
}
break
}
}
conn.SetWriteDeadline(time.Time{})
}
}
20 changes: 6 additions & 14 deletions backend/pkg/transport/network/tcp/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,9 @@ func NewClientConfig(laddr net.Addr) ClientConfig {
Dialer: net.Dialer{
Timeout: time.Second,
LocalAddr: laddr,
KeepAlive: -1, // managed via KeepAliveConfig
KeepAliveConfig: net.KeepAliveConfig{
Enable: true,
Idle: time.Second,
Interval: time.Second,
Count: 3,
},
// Kernel keep-alive disabled: liveness is handled by the
// application-level keep-alive order (transport.HandleKeepAlive)
KeepAlive: -1,
},

Context: context.TODO(),
Expand Down Expand Up @@ -73,13 +69,9 @@ type ServerConfig struct {
func NewServerConfig() ServerConfig {
return ServerConfig{
ListenConfig: net.ListenConfig{
KeepAlive: -1, // managed via KeepAliveConfig
KeepAliveConfig: net.KeepAliveConfig{
Enable: true,
Idle: time.Second,
Interval: time.Second,
Count: 3,
},
// Kernel keep-alive disabled: liveness is handled by the
// application-level keep-alive order (transport.HandleKeepAlive)
KeepAlive: -1,
},
Context: context.TODO(),
}
Expand Down
14 changes: 14 additions & 0 deletions backend/pkg/websocket/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import (
ws "github.com/gorilla/websocket"
)

// HeartbeatTopic is the liveness message every frontend sends once per
// second. It is consumed by the websocket layer and never reaches the broker.
const HeartbeatTopic = "connection/heartbeat"

// heartbeatTimeout bounds how long a dead or frozen frontend goes undetected:
// if no message (the heartbeat guarantees one per second) arrives within this
// window, Read fails and the client is treated as disconnected.
const heartbeatTimeout = 3 * time.Second

type Client struct {
readMx *sync.Mutex
writeMx *sync.Mutex
Expand All @@ -26,6 +35,8 @@ func NewClient(conn *ws.Conn) *Client {
onClose: func() {},
}

conn.SetReadDeadline(time.Now().Add(heartbeatTimeout))

return client
}

Expand All @@ -46,6 +57,9 @@ func (client *Client) Read() (Message, error) {

var message Message
err := client.conn.ReadJSON(&message)
if err == nil {
client.conn.SetReadDeadline(time.Now().Add(heartbeatTimeout))
}
return message, err
}

Expand Down
7 changes: 7 additions & 0 deletions backend/pkg/websocket/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ func (pool *Pool) handle(id ClientId, client *Client) {
}

clientLogger.Trace().Msg("read")

// Heartbeats only refresh the read deadline; they carry no payload
// for the broker.
if string(message.Topic) == HeartbeatTopic {
continue
}

pool.onMessage(id, &message)
}
}
Expand Down
Loading
Loading