Compare commits

...

8 Commits

Author SHA1 Message Date
Marc Brooks 8cdaf779c4
Merge 926fcf202a into 488276f3a8 2025-07-09 23:27:33 +01:00
adammkelly 488276f3a8
feat(ui): reboot device (#421) (#505) 2025-07-10 00:02:13 +02:00
Patrick Hofmann 7267347261
feat(dc-power-extension): power restore mode in DCPowerControl component (#672)
* DC-extension: Supporting to set the power restore mode in DCPowerControl component

* fixing lint issue
2025-07-09 23:58:46 +02:00
Marc Brooks 393bc122d4
chore: fix the base usb configuration (#610)
In reviewing the config.go settings for idProduct and bcdDevice are not formatted correctly. All examples on GitHub have 0x0104 and 0x0100 respectively. The idProduct value gets overwritten with valid values when you change the configuration (because they are correct in the options), but until you do the USB initialization will not be correct.
2025-07-09 23:57:51 +02:00
Marc Brooks 6d13e1be12
chore: remove ActionBar-Ctrl-Alt-Del (#669) 2025-07-09 23:53:44 +02:00
Marc Brooks 926fcf202a
Add support for using DHCP-provided NTP server 2025-06-18 21:09:45 -05:00
Marc Brooks 087487fe9c
Add custom NTP and HTTP time sync servers
Since the ordering may have been previously defaulted and saved as "ntp,http", but that was being ignored and fallback-defaults were being used, in Ordering, `ntp` means use the fallback NTP servers, and `http` means use the fallback HTTP URLs. Thus `ntp_user_provided` and `http_user_provided` are the user specified static lists.
2025-06-18 20:27:02 -05:00
Marc Brooks 466bf40658
Ensure the mDNS mode is set every time network state changes
Eliminates (mostly) duplicate code
2025-06-18 12:38:27 -05:00
19 changed files with 339 additions and 110 deletions

View File

@ -43,9 +43,11 @@ type testNetworkConfig struct {
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"` TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"`
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"` TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"` TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
TimeSyncNTPServers []string `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_user_provided"`
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
} }
func TestValidateConfig(t *testing.T) { func TestValidateConfig(t *testing.T) {

View File

@ -45,9 +45,11 @@ type NetworkConfig struct {
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"` TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"`
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"` TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"` TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
TimeSyncNTPServers []string `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_user_provided"`
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
} }
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions { func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {

View File

@ -21,6 +21,7 @@ type NetworkInterfaceState struct {
ipv6Addr *net.IP ipv6Addr *net.IP
ipv6Addresses []IPv6Address ipv6Addresses []IPv6Address
ipv6LinkLocal *net.IP ipv6LinkLocal *net.IP
ntpAddresses []*net.IP
macAddr *net.HardwareAddr macAddr *net.HardwareAddr
l *zerolog.Logger l *zerolog.Logger
@ -76,6 +77,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
onInitialCheck: opts.OnInitialCheck, onInitialCheck: opts.OnInitialCheck,
cbConfigChange: opts.OnConfigChange, cbConfigChange: opts.OnConfigChange,
config: opts.NetworkConfig, config: opts.NetworkConfig,
ntpAddresses: make([]*net.IP, 0),
} }
// create the dhcp client // create the dhcp client
@ -89,7 +91,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
opts.Logger.Error().Err(err).Msg("failed to update network state") opts.Logger.Error().Err(err).Msg("failed to update network state")
return return
} }
_ = s.updateNtpServersFromLease(lease)
_ = s.setHostnameIfNotSame() _ = s.setHostnameIfNotSame()
opts.OnDhcpLeaseChange(lease) opts.OnDhcpLeaseChange(lease)
@ -135,6 +137,27 @@ func (s *NetworkInterfaceState) IPv6String() string {
return s.ipv6Addr.String() return s.ipv6Addr.String()
} }
func (s *NetworkInterfaceState) NtpAddresses() []*net.IP {
return s.ntpAddresses
}
func (s *NetworkInterfaceState) NtpAddressesString() []string {
ntpServers := []string{}
if s != nil {
s.l.Debug().Any("s", s).Msg("getting NTP address strings")
if len(s.ntpAddresses) > 0 {
for _, server := range s.ntpAddresses {
s.l.Debug().IPAddr("server", *server).Msg("converting NTP address")
ntpServers = append(ntpServers, server.String())
}
}
}
return ntpServers
}
func (s *NetworkInterfaceState) MAC() *net.HardwareAddr { func (s *NetworkInterfaceState) MAC() *net.HardwareAddr {
return s.macAddr return s.macAddr
} }
@ -318,6 +341,25 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
return dhcpTargetState, nil return dhcpTargetState, nil
} }
func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *udhcpc.Lease) error {
if lease != nil && len(lease.NTPServers) > 0 {
s.l.Info().Msg("lease found, updating DHCP NTP addresses")
s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers))
for _, ntpServer := range lease.NTPServers {
if ntpServer != nil {
s.l.Info().IPAddr("ntp_server", ntpServer).Msg("NTP server found in lease")
s.ntpAddresses = append(s.ntpAddresses, &ntpServer)
}
}
} else {
s.l.Info().Msg("no NTP servers found in lease")
s.ntpAddresses = make([]*net.IP, 0, len(s.config.TimeSyncNTPServers))
}
return nil
}
func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error {
dhcpTargetState, err := s.update() dhcpTargetState, err := s.update()
if err != nil { if err != nil {

View File

@ -19,9 +19,9 @@ var defaultHTTPUrls = []string{
// "http://www.msftconnecttest.com/connecttest.txt", // "http://www.msftconnecttest.com/connecttest.txt",
} }
func (t *TimeSync) queryAllHttpTime() (now *time.Time) { func (t *TimeSync) queryAllHttpTime(httpUrls []string) (now *time.Time) {
chunkSize := 4 chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
httpUrls := t.httpUrls t.l.Info().Strs("httpUrls", httpUrls).Int("chunkSize", chunkSize).Msg("querying HTTP URLs")
// shuffle the http urls to avoid always querying the same servers // shuffle the http urls to avoid always querying the same servers
rand.Shuffle(len(httpUrls), func(i, j int) { httpUrls[i], httpUrls[j] = httpUrls[j], httpUrls[i] }) rand.Shuffle(len(httpUrls), func(i, j int) { httpUrls[i], httpUrls[j] = httpUrls[j], httpUrls[i] })

View File

@ -73,6 +73,7 @@ var (
}, },
[]string{"url"}, []string{"url"},
) )
metricNtpServerInfo = promauto.NewGaugeVec( metricNtpServerInfo = promauto.NewGaugeVec(
prometheus.GaugeOpts{ prometheus.GaugeOpts{
Name: "jetkvm_timesync_ntp_server_info", Name: "jetkvm_timesync_ntp_server_info",

View File

@ -1,6 +1,7 @@
package timesync package timesync
import ( import (
"context"
"math/rand/v2" "math/rand/v2"
"strconv" "strconv"
"time" "time"
@ -21,9 +22,9 @@ var defaultNTPServers = []string{
"3.pool.ntp.org", "3.pool.ntp.org",
} }
func (t *TimeSync) queryNetworkTime() (now *time.Time, offset *time.Duration) { func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) {
chunkSize := 4 chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
ntpServers := t.ntpServers t.l.Info().Strs("servers", ntpServers).Int("chunkSize", chunkSize).Msg("querying NTP servers")
// shuffle the ntp servers to avoid always querying the same servers // shuffle the ntp servers to avoid always querying the same servers
rand.Shuffle(len(ntpServers), func(i, j int) { ntpServers[i], ntpServers[j] = ntpServers[j], ntpServers[i] }) rand.Shuffle(len(ntpServers), func(i, j int) { ntpServers[i], ntpServers[j] = ntpServers[j], ntpServers[i] })
@ -46,6 +47,10 @@ type ntpResult struct {
func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (now *time.Time, offset *time.Duration) { func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (now *time.Time, offset *time.Duration) {
results := make(chan *ntpResult, len(servers)) results := make(chan *ntpResult, len(servers))
_, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for _, server := range servers { for _, server := range servers {
go func(server string) { go func(server string) {
scopedLogger := t.l.With(). scopedLogger := t.l.With().
@ -66,15 +71,25 @@ func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (no
return return
} }
if response.IsKissOfDeath() {
scopedLogger.Warn().
Str("kiss_code", response.KissCode).
Msg("ignoring NTP server kiss of death")
results <- nil
return
}
rtt := float64(response.RTT.Milliseconds())
// set the last RTT // set the last RTT
metricNtpServerLastRTT.WithLabelValues( metricNtpServerLastRTT.WithLabelValues(
server, server,
).Set(float64(response.RTT.Milliseconds())) ).Set(rtt)
// set the RTT histogram // set the RTT histogram
metricNtpServerRttHistogram.WithLabelValues( metricNtpServerRttHistogram.WithLabelValues(
server, server,
).Observe(float64(response.RTT.Milliseconds())) ).Observe(rtt)
// set the server info // set the server info
metricNtpServerInfo.WithLabelValues( metricNtpServerInfo.WithLabelValues(
@ -91,10 +106,13 @@ func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (no
scopedLogger.Info(). scopedLogger.Info().
Str("time", now.Format(time.RFC3339)). Str("time", now.Format(time.RFC3339)).
Str("reference", response.ReferenceString()). Str("reference", response.ReferenceString()).
Str("rtt", response.RTT.String()). Float64("rtt", rtt).
Str("clockOffset", response.ClockOffset.String()). Str("clockOffset", response.ClockOffset.String()).
Uint8("stratum", response.Stratum). Uint8("stratum", response.Stratum).
Msg("NTP server returned time") Msg("NTP server returned time")
cancel()
results <- &ntpResult{ results <- &ntpResult{
now: now, now: now,
offset: &response.ClockOffset, offset: &response.ClockOffset,

View File

@ -28,9 +28,8 @@ type TimeSync struct {
syncLock *sync.Mutex syncLock *sync.Mutex
l *zerolog.Logger l *zerolog.Logger
ntpServers []string
httpUrls []string
networkConfig *network.NetworkConfig networkConfig *network.NetworkConfig
dhcpNtpAddresses []string
rtcDevicePath string rtcDevicePath string
rtcDevice *os.File //nolint:unused rtcDevice *os.File //nolint:unused
@ -66,11 +65,10 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
t := &TimeSync{ t := &TimeSync{
syncLock: &sync.Mutex{}, syncLock: &sync.Mutex{},
l: opts.Logger, l: opts.Logger,
dhcpNtpAddresses: []string{},
rtcDevicePath: rtcDevice, rtcDevicePath: rtcDevice,
rtcLock: &sync.Mutex{}, rtcLock: &sync.Mutex{},
preCheckFunc: opts.PreCheckFunc, preCheckFunc: opts.PreCheckFunc,
ntpServers: defaultNTPServers,
httpUrls: defaultHTTPUrls,
networkConfig: opts.NetworkConfig, networkConfig: opts.NetworkConfig,
} }
@ -82,34 +80,42 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
return t return t
} }
func (t *TimeSync) SetDhcpNtpAddresses(addresses []string) {
t.dhcpNtpAddresses = addresses
}
func (t *TimeSync) getSyncMode() SyncMode { func (t *TimeSync) getSyncMode() SyncMode {
syncMode := SyncMode{ syncMode := SyncMode{
Ntp: true,
Http: true,
Ordering: []string{"ntp_dhcp", "ntp", "http"},
NtpUseFallback: true, NtpUseFallback: true,
HttpUseFallback: true, HttpUseFallback: true,
} }
var syncModeString string
if t.networkConfig != nil { if t.networkConfig != nil {
syncModeString = t.networkConfig.TimeSyncMode.String switch t.networkConfig.TimeSyncMode.String {
case "ntp_only":
syncMode.Http = false
case "http_only":
syncMode.Ntp = false
}
if t.networkConfig.TimeSyncDisableFallback.Bool { if t.networkConfig.TimeSyncDisableFallback.Bool {
syncMode.NtpUseFallback = false syncMode.NtpUseFallback = false
syncMode.HttpUseFallback = false syncMode.HttpUseFallback = false
} }
var syncOrdering = t.networkConfig.TimeSyncOrdering
if len(syncOrdering) > 0 {
syncMode.Ordering = syncOrdering
}
} }
switch syncModeString { t.l.Debug().Strs("Ordering", syncMode.Ordering).Bool("Ntp", syncMode.Ntp).Bool("Http", syncMode.Http).Bool("NtpUseFallback", syncMode.NtpUseFallback).Bool("HttpUseFallback", syncMode.HttpUseFallback).Msg("sync mode")
case "ntp_only":
syncMode.Ntp = true
case "http_only":
syncMode.Http = true
default:
syncMode.Ntp = true
syncMode.Http = true
}
return syncMode return syncMode
} }
func (t *TimeSync) doTimeSync() { func (t *TimeSync) doTimeSync() {
metricTimeSyncStatus.Set(0) metricTimeSyncStatus.Set(0)
for { for {
@ -154,16 +160,61 @@ func (t *TimeSync) Sync() error {
offset *time.Duration offset *time.Duration
) )
syncMode := t.getSyncMode()
metricTimeSyncCount.Inc() metricTimeSyncCount.Inc()
if syncMode.Ntp { syncMode := t.getSyncMode()
now, offset = t.queryNetworkTime()
}
if syncMode.Http && now == nil { Orders:
now = t.queryAllHttpTime() for _, mode := range syncMode.Ordering {
switch mode {
case "ntp_user_provided":
if syncMode.Ntp {
t.l.Info().Msg("using NTP custom servers")
now, offset = t.queryNetworkTime(t.networkConfig.TimeSyncNTPServers)
if now != nil {
t.l.Info().Str("source", "NTP").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "ntp_dhcp":
if syncMode.Ntp {
t.l.Info().Msg("using NTP servers from DHCP")
now, offset = t.queryNetworkTime(t.dhcpNtpAddresses)
if now != nil {
t.l.Info().Str("source", "NTP DHCP").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "ntp":
if syncMode.Ntp && syncMode.NtpUseFallback {
t.l.Info().Msg("using NTP fallback")
now, offset = t.queryNetworkTime(defaultNTPServers)
if now != nil {
t.l.Info().Str("source", "NTP fallback").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "http_user_provided":
if syncMode.Http {
t.l.Info().Msg("using HTTP custom URLs")
now = t.queryAllHttpTime(t.networkConfig.TimeSyncHTTPUrls)
if now != nil {
t.l.Info().Str("source", "HTTP").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "http":
if syncMode.Http && syncMode.HttpUseFallback {
t.l.Info().Msg("using HTTP fallback")
now = t.queryAllHttpTime(defaultHTTPUrls)
if now != nil {
t.l.Info().Str("source", "HTTP fallback").Time("now", *now).Msg("time obtained")
break Orders
}
}
default:
t.l.Warn().Str("mode", mode).Msg("unknown time sync mode, skipping")
}
} }
if now == nil { if now == nil {

View File

@ -30,8 +30,8 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
attrs: gadgetAttributes{ attrs: gadgetAttributes{
"bcdUSB": "0x0200", // USB 2.0 "bcdUSB": "0x0200", // USB 2.0
"idVendor": "0x1d6b", // The Linux Foundation "idVendor": "0x1d6b", // The Linux Foundation
"idProduct": "0104", // Multifunction Composite Gadget "idProduct": "0x0104", // Multifunction Composite Gadget
"bcdDevice": "0100", "bcdDevice": "0x0100", // USB2
}, },
configAttrs: gadgetAttributes{ configAttrs: gadgetAttributes{
"MaxPower": "250", // in unit of 2mA "MaxPower": "250", // in unit of 2mA

View File

@ -685,6 +685,7 @@ type DCPowerState struct {
Voltage float64 `json:"voltage"` Voltage float64 `json:"voltage"`
Current float64 `json:"current"` Current float64 `json:"current"`
Power float64 `json:"power"` Power float64 `json:"power"`
RestoreState int `json:"restoreState"`
} }
func rpcGetDCPowerState() (DCPowerState, error) { func rpcGetDCPowerState() (DCPowerState, error) {
@ -700,6 +701,15 @@ func rpcSetDCPowerState(enabled bool) error {
return nil return nil
} }
func rpcSetDCRestoreState(state int) error {
logger.Info().Int("state", state).Msg("Setting DC restore state")
err := setDCRestoreState(state)
if err != nil {
return fmt.Errorf("failed to set DC restore state: %w", err)
}
return nil
}
func rpcGetActiveExtension() (string, error) { func rpcGetActiveExtension() (string, error) {
return config.ActiveExtension, nil return config.ActiveExtension, nil
} }
@ -1088,6 +1098,7 @@ var rpcHandlers = map[string]RPCHandler{
"getBacklightSettings": {Func: rpcGetBacklightSettings}, "getBacklightSettings": {Func: rpcGetBacklightSettings},
"getDCPowerState": {Func: rpcGetDCPowerState}, "getDCPowerState": {Func: rpcGetDCPowerState},
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}}, "setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
"getActiveExtension": {Func: rpcGetActiveExtension}, "getActiveExtension": {Func: rpcGetActiveExtension},
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}}, "setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
"getATXState": {Func: rpcGetATXState}, "getATXState": {Func: rpcGetATXState},

View File

@ -19,6 +19,14 @@ func networkStateChanged() {
// do not block the main thread // do not block the main thread
go waitCtrlAndRequestDisplayUpdate(true) go waitCtrlAndRequestDisplayUpdate(true)
if timeSync != nil {
if networkState != nil {
timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString())
}
timeSync.Sync()
}
// always restart mDNS when the network state changes // always restart mDNS when the network state changes
if mDNS != nil { if mDNS != nil {
_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode()) _ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())

View File

@ -142,6 +142,7 @@ var dcState DCPowerState
func runDCControl() { func runDCControl() {
scopedLogger := serialLogger.With().Str("service", "dc_control").Logger() scopedLogger := serialLogger.With().Str("service", "dc_control").Logger()
reader := bufio.NewReader(port) reader := bufio.NewReader(port)
hasRestoreFeature := false
for { for {
line, err := reader.ReadString('\n') line, err := reader.ReadString('\n')
if err != nil { if err != nil {
@ -151,7 +152,13 @@ func runDCControl() {
// Split the line by semicolon // Split the line by semicolon
parts := strings.Split(strings.TrimSpace(line), ";") parts := strings.Split(strings.TrimSpace(line), ";")
if len(parts) != 4 { if len(parts) == 5 {
scopedLogger.Debug().Str("line", line).Msg("Detected DC extension with restore feature")
hasRestoreFeature = true
} else if len(parts) == 4 {
scopedLogger.Debug().Str("line", line).Msg("Detected DC extension without restore feature")
hasRestoreFeature = false
} else {
scopedLogger.Warn().Str("line", line).Msg("Invalid line") scopedLogger.Warn().Str("line", line).Msg("Invalid line")
continue continue
} }
@ -163,6 +170,17 @@ func runDCControl() {
continue continue
} }
dcState.IsOn = powerState == 1 dcState.IsOn = powerState == 1
if hasRestoreFeature {
restoreState, err := strconv.Atoi(parts[4])
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid restore state")
continue
}
dcState.RestoreState = restoreState
} else {
// -1 means not supported
dcState.RestoreState = -1
}
milliVolts, err := strconv.ParseFloat(parts[1], 64) milliVolts, err := strconv.ParseFloat(parts[1], 64)
if err != nil { if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid voltage") scopedLogger.Warn().Err(err).Msg("Invalid voltage")
@ -210,6 +228,25 @@ func setDCPowerState(on bool) error {
return nil return nil
} }
func setDCRestoreState(state int) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
command := "RESTORE_MODE_OFF\n"
switch state {
case 1:
command = "RESTORE_MODE_ON\n"
case 2:
command = "RESTORE_MODE_LAST_STATE\n"
}
_, err = port.Write([]byte(command))
if err != nil {
return err
}
return nil
}
var defaultMode = &serial.Mode{ var defaultMode = &serial.Mode{
BaudRate: 115200, BaudRate: 115200,
DataBits: 8, DataBits: 8,

View File

@ -262,23 +262,6 @@ export default function Actionbar({
}} }}
/> />
</div> </div>
{/* {useSettingsStore().actionBarCtrlAltDel && (
<div className="hidden lg:block">
<Button
size="XS"
theme="light"
text="Ctrl + Alt + Del"
LeadingIcon={FaLock}
onClick={() => {
sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
}}
/>
</div>
)} */}
<div> <div>
<Button <Button
size="XS" size="XS"

View File

@ -8,12 +8,14 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications"; import notifications from "@/notifications";
import FieldLabel from "@components/FieldLabel"; import FieldLabel from "@components/FieldLabel";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import {SelectMenuBasic} from "@components/SelectMenuBasic";
interface DCPowerState { interface DCPowerState {
isOn: boolean; isOn: boolean;
voltage: number; voltage: number;
current: number; current: number;
power: number; power: number;
restoreState: number;
} }
export function DCPowerControl() { export function DCPowerControl() {
@ -43,6 +45,20 @@ export function DCPowerControl() {
getDCPowerState(); // Refresh state after change getDCPowerState(); // Refresh state after change
}); });
}; };
const handleRestoreChange = (state: number) => {
// const state = powerState?.restoreState === 0 ? 1 : powerState?.restoreState === 1 ? 2 : 0;
send("setDCRestoreState", { state }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
);
return;
}
getDCPowerState(); // Refresh state after change
});
};
useEffect(() => { useEffect(() => {
getDCPowerState(); getDCPowerState();
@ -63,7 +79,7 @@ export function DCPowerControl() {
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" /> <LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
</Card> </Card>
) : ( ) : (
<Card className="h-[160px] animate-fadeIn opacity-0"> <Card className="animate-fadeIn opacity-0">
<div className="space-y-4 p-3"> <div className="space-y-4 p-3">
{/* Power Controls */} {/* Power Controls */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -84,6 +100,21 @@ export function DCPowerControl() {
onClick={() => handlePowerToggle(false)} onClick={() => handlePowerToggle(false)}
/> />
</div> </div>
{powerState.restoreState > -1 ? (
<div className="flex items-center">
<SelectMenuBasic
size="SM"
label="Restore Power Loss"
value={powerState.restoreState}
onChange={e => handleRestoreChange(parseInt(e.target.value))}
options={[
{ value: '0', label: "Power OFF" },
{ value: '1', label: "Power ON" },
{ value: '2', label: "Last State" },
]}
/>
</div>
) : null}
<hr className="border-slate-700/30 dark:border-slate-600/30" /> <hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Status Display */} {/* Status Display */}

View File

@ -308,9 +308,6 @@ interface SettingsState {
keyboardLayout: string; keyboardLayout: string;
setKeyboardLayout: (layout: string) => void; setKeyboardLayout: (layout: string) => void;
actionBarCtrlAltDel: boolean;
setActionBarCtrlAltDel: (enabled: boolean) => void;
keyboardLedSync: KeyboardLedSync; keyboardLedSync: KeyboardLedSync;
setKeyboardLedSync: (sync: KeyboardLedSync) => void; setKeyboardLedSync: (sync: KeyboardLedSync) => void;
@ -359,9 +356,6 @@ export const useSettingsStore = create(
keyboardLayout: "en-US", keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }), setKeyboardLayout: layout => set({ keyboardLayout: layout }),
actionBarCtrlAltDel: false,
setActionBarCtrlAltDel: enabled => set({ actionBarCtrlAltDel: enabled }),
keyboardLedSync: "auto", keyboardLedSync: "auto",
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }), setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),

View File

@ -42,6 +42,7 @@ import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware";
import SettingsVideoRoute from "./routes/devices.$id.settings.video"; import SettingsVideoRoute from "./routes/devices.$id.settings.video";
import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance"; import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index"; import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
import SettingsGeneralRebootRoute from "./routes/devices.$id.settings.general.reboot";
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update"; import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
import SettingsNetworkRoute from "./routes/devices.$id.settings.network"; import SettingsNetworkRoute from "./routes/devices.$id.settings.network";
import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth"; import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
@ -140,6 +141,10 @@ if (isOnDevice) {
index: true, index: true,
element: <SettingsGeneralIndexRoute.default />, element: <SettingsGeneralIndexRoute.default />,
}, },
{
path: "reboot",
element: <SettingsGeneralRebootRoute />,
},
{ {
path: "update", path: "update",
element: <SettingsGeneralUpdateRoute />, element: <SettingsGeneralUpdateRoute />,

View File

@ -1,28 +0,0 @@
import { Checkbox } from "@/components/Checkbox";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { useSettingsStore } from "@/hooks/stores";
import { SettingsItem } from "./devices.$id.settings";
export default function SettingsCtrlAltDelRoute() {
const enableCtrlAltDel = useSettingsStore(state => state.actionBarCtrlAltDel);
const setEnableCtrlAltDel = useSettingsStore(state => state.setActionBarCtrlAltDel);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Action Bar"
description="Customize the action bar of your JetKVM interface"
/>
<div className="space-y-4">
<SettingsItem title="Enable Ctrl-Alt-Del" description="Enable the Ctrl-Alt-Del key on the virtual keyboard">
<Checkbox
checked={enableCtrlAltDel}
onChange={e => setEnableCtrlAltDel(e.target.checked)}
/>
</SettingsItem>
</div>
</div>
);
}

View File

@ -92,6 +92,21 @@ export default function SettingsGeneralRoute() {
/> />
</SettingsItem> </SettingsItem>
</div> </div>
<div className="mt-2 flex items-center justify-between gap-x-2">
<SettingsItem
title="Reboot Device"
description="Power cycle the JetKVM"
/>
<div>
<Button
size="SM"
theme="light"
text="Reboot Device"
onClick={() => navigateTo("./reboot")}
/>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,66 @@
import { useNavigate } from "react-router-dom";
import { useCallback } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
export default function SettingsGeneralRebootRoute() {
const navigate = useNavigate();
const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
// This is where we send the RPC to the golang binary
send("reboot", {force: true});
}, [send]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
}
export function Dialog({
onClose,
onConfirmUpdate,
}: {
onClose: () => void;
onConfirmUpdate: () => void;
}) {
return (
<div className="pointer-events-auto relative mx-auto text-left">
<div>
<ConfirmationBox
onYes={onConfirmUpdate}
onNo={onClose}
/>
</div>
</div>
);
}
function ConfirmationBox({
onYes,
onNo,
}: {
onYes: () => void;
onNo: () => void;
}) {
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
Reboot JetKVM
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
Do you want to proceed with rebooting the system?
</p>
<div className="mt-4 flex gap-x-2">
<Button size="SM" theme="light" text="Yes" onClick={onYes} />
<Button size="SM" theme="blank" text="No" onClick={onNo} />
</div>
</div>
</div>
);
}

View File

@ -116,15 +116,6 @@ export default function SettingsHardwareRoute() {
}} }}
/> />
</SettingsItem> </SettingsItem>
{/* <SettingsItem
title="Enable Ctrl+Alt+Del Action Bar"
description="Enable or disable the action bar action for sending a Ctrl+Alt+Del to the host"
>
<Checkbox
checked={actionBarConfig.ctrlAltDel}
onChange={onActionBarItemChange("ctrlAltDel")}
/>
</SettingsItem> */}
{settings.backlightSettings.max_brightness != 0 && ( {settings.backlightSettings.max_brightness != 0 && (
<> <>
<SettingsItem <SettingsItem