mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
User-specified direct path
This commit is contained in:
@@ -30,6 +30,40 @@ var perTryReply = 10 * time.Second
|
||||
|
||||
const maxSendTries = 4
|
||||
|
||||
// applyUserPath seeds the exchanger with a caller-supplied route (the optional
|
||||
// ?path= query param from the confirm/console page) so the login and commands
|
||||
// route directly with flood fallback. A malformed path is reported and ignored
|
||||
// (we fall back to flood) rather than failing the session. It returns whether a
|
||||
// path was set, so the caller can report whether that path actually worked.
|
||||
func applyUserPath(ex *mesh.Exchanger, r *http.Request, bridge *wsbridge.Conn) bool {
|
||||
raw := r.URL.Query().Get("path")
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
path, pathLen, err := mesh.ParsePath(raw)
|
||||
if err != nil {
|
||||
_ = bridge.Status("warning", "Ignoring the path you entered ("+err.Error()+") — using flood.")
|
||||
return false
|
||||
}
|
||||
if path == nil {
|
||||
return false
|
||||
}
|
||||
ex.SetPath(path, pathLen)
|
||||
_ = bridge.Status("info", "Using the path you specified (direct routing, with flood fallback).")
|
||||
return true
|
||||
}
|
||||
|
||||
// reportPathOutcome logs whether the login reached the repeater over the
|
||||
// user-supplied path (a direct RESPONSE reply) or had to fall back to flood (a
|
||||
// PATH return reply). Only meaningful when a path was set and login succeeded.
|
||||
func reportPathOutcome(bridge *wsbridge.Conn, lr *mesh.LoginResponse) {
|
||||
if lr.FromPath {
|
||||
_ = bridge.Status("warning", "The path you specified didn't get through — reached the repeater by flood instead.")
|
||||
} else {
|
||||
_ = bridge.Status("info", "Reached the repeater directly over the path you specified. ✓")
|
||||
}
|
||||
}
|
||||
|
||||
// pageConfirm renders the WebSerial confirm page for a repeater the user can access.
|
||||
func (s *Handlers) pageConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
rep, _, ok := s.requireRepeaterAccess(w, r)
|
||||
@@ -89,6 +123,7 @@ func (s *Handlers) wsConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
modem.SetDataHandler(func(data []byte, _ float32, _ int8, _ bool) {
|
||||
ex.HandleData(data)
|
||||
})
|
||||
userPathSet := applyUserPath(ex, r, bridge)
|
||||
if debug {
|
||||
// Dump every inbound KISS frame as hex so we can see exactly what the
|
||||
// modem reports back (e.g. whether the repeater replies at all).
|
||||
@@ -163,6 +198,9 @@ func (s *Handlers) wsConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
return // context cancelled or a build/transmit error already reported
|
||||
}
|
||||
if userPathSet {
|
||||
reportPathOutcome(bridge, lr)
|
||||
}
|
||||
|
||||
if err := s.Store.SetRepeaterConfirmed(ctx, id, uid, lr.IsAdmin, int16(lr.Permissions)); err != nil {
|
||||
_ = bridge.Status("error", "could not save confirmation: "+err.Error())
|
||||
|
||||
@@ -206,6 +206,7 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) {
|
||||
modem.SetDataHandler(func(data []byte, _ float32, _ int8, _ bool) {
|
||||
ex.HandleData(data)
|
||||
})
|
||||
userPathSet := applyUserPath(ex, r, bridge)
|
||||
|
||||
// Group this connection's commands into a session (required for logging).
|
||||
sessionID, err := s.Store.StartConsoleSession(ctx, id, uid)
|
||||
@@ -285,14 +286,18 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) {
|
||||
// direct routing. If login gets no reply we proceed anyway — the repeater may
|
||||
// still have us cached as an admin client from an earlier session.
|
||||
_ = bridge.Status("info", "Establishing session…")
|
||||
if _, err := ex.Login(ctx, "", func(attempt, max int) {
|
||||
lr, err := ex.Login(ctx, "", func(attempt, max int) {
|
||||
if attempt > 1 {
|
||||
_ = bridge.Status("info", fmt.Sprintf("No reply yet — retrying (%d/%d)…", attempt, max))
|
||||
}
|
||||
}); errors.Is(err, mesh.ErrNoReply) {
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, mesh.ErrNoReply):
|
||||
_ = bridge.Status("warning", "Couldn't reach the repeater to establish a session — commands will still be attempted (flood), but may not work if it doesn't recognize MeshTender.")
|
||||
} else if err != nil {
|
||||
case err != nil:
|
||||
return // context cancelled
|
||||
case userPathSet:
|
||||
reportPathOutcome(bridge, lr)
|
||||
}
|
||||
_ = bridge.Status("info", "Connected. Ready for commands.")
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
This browser does not support WebSerial. Use Chrome, Edge, or another Chromium-based browser over HTTPS or on localhost.
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label" for="path">Path <span class="form-label-description">optional</span></label>
|
||||
<input type="text" class="form-control font-monospace" id="path" placeholder="e.g. 11, 22, 33" autocomplete="off" spellcheck="false">
|
||||
<small class="form-hint">Hex hops from your modem to the repeater, in order, same length each (e.g. <code>11, 22, 33</code>). Leave blank to reach it by flood.</small>
|
||||
</div>
|
||||
|
||||
<button id="connect" class="btn btn-primary mt-3" type="button">{{template "icon-plug" "me-1"}}Connect modem & confirm</button>
|
||||
|
||||
<ul id="log" class="evlog mt-3" role="log" aria-live="polite" aria-label="Confirmation activity"></ul>
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
<div id="unsupported" class="alert alert-danger" role="alert" hidden>
|
||||
This browser does not support WebSerial. Use Chrome, Edge, or another Chromium-based browser over HTTPS or on localhost.
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="path">Path <span class="form-label-description">optional</span></label>
|
||||
<input type="text" class="form-control font-monospace" id="path" placeholder="e.g. 11, 22, 33" autocomplete="off" spellcheck="false">
|
||||
<small class="form-hint">Hex hops from your modem to the repeater, in order, same length each (e.g. <code>11, 22, 33</code>). Leave blank to reach it by flood.</small>
|
||||
</div>
|
||||
<button id="connect" class="btn btn-primary" type="button">{{template "icon-plug" "me-1"}}Connect modem</button>
|
||||
<ul id="log" class="evlog console mt-3" role="log" aria-live="polite" aria-label="Console activity"></ul>
|
||||
<form id="cmdform" class="input-group mt-3" autocomplete="off" hidden>
|
||||
|
||||
@@ -136,8 +136,16 @@ func (e *Exchanger) exchange(ctx context.Context, build func(ts time.Time, attem
|
||||
func (e *Exchanger) Login(ctx context.Context, password string, onAttempt func(attempt, max int)) (*LoginResponse, error) {
|
||||
var result *LoginResponse
|
||||
err := e.exchange(ctx,
|
||||
func(ts time.Time, _ int) ([]byte, error) {
|
||||
// Login is always flood: it's the first contact, before any path is known.
|
||||
func(ts time.Time, attempt int) ([]byte, error) {
|
||||
// With a path known (learned or user-supplied), route the login directly
|
||||
// on early attempts; fall back to flood on later ones in case it's stale.
|
||||
e.mu.Lock()
|
||||
direct := e.pathKnown && attempt <= e.directThreshold()
|
||||
path, pathLen := e.outPath, e.outPathLen
|
||||
e.mu.Unlock()
|
||||
if direct {
|
||||
return BuildLoginPacketDirect(e.server, e.repeater, password, ts, path, pathLen)
|
||||
}
|
||||
return BuildLoginPacket(e.server, e.repeater, password, ts)
|
||||
},
|
||||
func(raw []byte) bool {
|
||||
@@ -153,15 +161,30 @@ func (e *Exchanger) Login(ctx context.Context, password string, onAttempt func(a
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Learn the route home so subsequent commands can use direct routing.
|
||||
e.mu.Lock()
|
||||
e.pathKnown = true
|
||||
e.outPath = result.OutPath
|
||||
e.outPathLen = result.OutPathLen
|
||||
// Only adopt the route from the reply when it carried one (a PATH reply from a
|
||||
// flooded login). A direct login answered by a plain RESPONSE has no path, so
|
||||
// keep the path we already had (e.g. one the user supplied) rather than wiping it.
|
||||
if result.FromPath {
|
||||
e.outPath = result.OutPath
|
||||
e.outPathLen = result.OutPathLen
|
||||
}
|
||||
e.mu.Unlock()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SetPath pre-seeds the route to the repeater (e.g. a user-supplied path), so the
|
||||
// login and subsequent commands route directly from the start, with flood as the
|
||||
// fallback. path/pathLen are as in LoginResponse.OutPath/OutPathLen.
|
||||
func (e *Exchanger) SetPath(path []byte, pathLen byte) {
|
||||
e.mu.Lock()
|
||||
e.pathKnown = true
|
||||
e.outPath = path
|
||||
e.outPathLen = pathLen
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// directThreshold is the highest attempt number that uses direct routing once a
|
||||
// path is known; later attempts fall back to flood in case the path is stale.
|
||||
func (e *Exchanger) directThreshold() int { return (e.tries + 1) / 2 }
|
||||
|
||||
+19
-5
@@ -49,10 +49,23 @@ type LoginResponse struct {
|
||||
}
|
||||
|
||||
// BuildLoginPacket builds the raw MeshCore LoRa packet bytes that perform an
|
||||
// anonymous login to repeater using server's identity. password may be empty
|
||||
// when access was granted via `setperm <pubkey> 3` (pubkey ACL). now is the
|
||||
// timestamp embedded in the request (the repeater uses it for clock sync).
|
||||
// anonymous login to repeater using server's identity, sent as flood (first
|
||||
// contact, before any path is known). password may be empty when access was
|
||||
// granted via `setperm <pubkey> 3` (pubkey ACL). now is the timestamp embedded
|
||||
// in the request (the repeater uses it for clock sync).
|
||||
func BuildLoginPacket(server meshcore.LocalIdentity, repeater meshcore.Identity, password string, now time.Time) ([]byte, error) {
|
||||
return buildLoginPacket(server, repeater, password, now, meshcore.RouteTypeFlood, floodPathLen, nil)
|
||||
}
|
||||
|
||||
// BuildLoginPacketDirect builds an anonymous login routed directly over a known
|
||||
// path (path/pathLen as in LoginResponse.OutPath/OutPathLen, or supplied by the
|
||||
// user) rather than flooded — useful for distant repeaters where the flood login
|
||||
// is unreliable.
|
||||
func BuildLoginPacketDirect(server meshcore.LocalIdentity, repeater meshcore.Identity, password string, now time.Time, path []byte, pathLen byte) ([]byte, error) {
|
||||
return buildLoginPacket(server, repeater, password, now, meshcore.RouteTypeDirect, pathLen, path)
|
||||
}
|
||||
|
||||
func buildLoginPacket(server meshcore.LocalIdentity, repeater meshcore.Identity, password string, now time.Time, routeType, pathLen byte, path []byte) ([]byte, error) {
|
||||
shared, err := server.SharedSecret(repeater)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive shared secret: %w", err)
|
||||
@@ -81,8 +94,9 @@ func BuildLoginPacket(server meshcore.LocalIdentity, repeater meshcore.Identity,
|
||||
}
|
||||
|
||||
pkt := &meshcore.Packet{
|
||||
Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypeAnonReq, 0),
|
||||
PathLength: floodPathLen,
|
||||
Header: meshcore.MakeHeader(routeType, meshcore.PayloadTypeAnonReq, 0),
|
||||
PathLength: pathLen,
|
||||
Path: path,
|
||||
Payload: payload,
|
||||
}
|
||||
raw, err := pkt.ToBytes()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxPathHops is the largest hop count a MeshCore path_len byte can describe: the
|
||||
// count is the low 6 bits (0x3f).
|
||||
const maxPathHops = 63
|
||||
|
||||
// ParsePath parses a user-entered repeater path into the raw path bytes and the
|
||||
// MeshCore path_len descriptor byte. The input is comma-separated hex hops, each
|
||||
// the same byte length (1, 2, or 3 bytes), e.g. "11, 22, 33" or "1111, 2222".
|
||||
// Hops are listed in path order (from the modem outward to the repeater).
|
||||
//
|
||||
// Empty/whitespace input returns (nil, 0, nil) — "no path", not an error. The
|
||||
// descriptor is (size-1)<<6 | count, matching floodPathLen and unwrapPathReturn.
|
||||
func ParsePath(s string) (path []byte, pathLen byte, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, 0, nil
|
||||
}
|
||||
var hops [][]byte
|
||||
size := -1
|
||||
for _, raw := range strings.Split(s, ",") {
|
||||
h := strings.TrimSpace(raw)
|
||||
if h == "" {
|
||||
return nil, 0, errors.New("a path hop is empty — use hex hops separated by commas, e.g. 11, 22, 33")
|
||||
}
|
||||
b, decErr := hex.DecodeString(h)
|
||||
if decErr != nil {
|
||||
return nil, 0, fmt.Errorf("hop %q isn't valid hex", h)
|
||||
}
|
||||
if len(b) < 1 || len(b) > 3 {
|
||||
return nil, 0, fmt.Errorf("hop %q must be 1, 2, or 3 bytes (2, 4, or 6 hex digits)", h)
|
||||
}
|
||||
if size == -1 {
|
||||
size = len(b)
|
||||
} else if len(b) != size {
|
||||
return nil, 0, errors.New("all path hops must be the same length")
|
||||
}
|
||||
hops = append(hops, b)
|
||||
}
|
||||
if len(hops) > maxPathHops {
|
||||
return nil, 0, fmt.Errorf("too many hops (%d) — the maximum is %d", len(hops), maxPathHops)
|
||||
}
|
||||
path = make([]byte, 0, len(hops)*size)
|
||||
for _, b := range hops {
|
||||
path = append(path, b...)
|
||||
}
|
||||
pathLen = byte((size-1)<<6 | len(hops)) //nolint:gosec // G115: size≤3 and hops≤63, so the value is ≤191 and fits a byte
|
||||
return path, pathLen, nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
meshcore "github.com/meshcore-go/meshcore-go"
|
||||
)
|
||||
|
||||
func TestParsePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
path []byte
|
||||
pathLen byte
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", nil, 0, false},
|
||||
{"whitespace", " ", nil, 0, false},
|
||||
{"one-byte hops", "11, 22, 33", []byte{0x11, 0x22, 0x33}, 0x03, false}, // size 1, count 3
|
||||
{"no spaces", "aa,bb", []byte{0xaa, 0xbb}, 0x02, false}, // size 1, count 2
|
||||
{"two-byte hops", "1111, 2222", []byte{0x11, 0x11, 0x22, 0x22}, (2-1)<<6 | 2, false},
|
||||
{"three-byte hops", "111111, 222222", []byte{0x11, 0x11, 0x11, 0x22, 0x22, 0x22}, (3-1)<<6 | 2, false},
|
||||
{"single hop", "a1b2c3", []byte{0xa1, 0xb2, 0xc3}, (3-1)<<6 | 1, false},
|
||||
{"mixed lengths", "11, 2222", nil, 0, true},
|
||||
{"bad hex", "11, zz", nil, 0, true},
|
||||
{"too many bytes per hop", "11223344", nil, 0, true},
|
||||
{"empty hop", "11,,22", nil, 0, true},
|
||||
{"odd hex", "111", nil, 0, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
path, pathLen, err := ParsePath(c.in)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("ParsePath(%q) = (%x, %d, nil), want error", c.in, path, pathLen)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePath(%q): %v", c.in, err)
|
||||
}
|
||||
if !bytes.Equal(path, c.path) || pathLen != c.pathLen {
|
||||
t.Errorf("ParsePath(%q) = (%x, %#x), want (%x, %#x)", c.in, path, pathLen, c.path, c.pathLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePathTooManyHops(t *testing.T) {
|
||||
t.Parallel()
|
||||
var b []byte
|
||||
for i := 0; i < maxPathHops+1; i++ {
|
||||
if i > 0 {
|
||||
b = append(b, ',')
|
||||
}
|
||||
b = append(b, '0', '1')
|
||||
}
|
||||
if _, _, err := ParsePath(string(b)); err == nil {
|
||||
t.Fatalf("want error for %d hops", maxPathHops+1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginUsesPresetPath verifies SetPath makes Login route directly with the
|
||||
// supplied path, and that a direct RESPONSE reply (no path of its own) does not
|
||||
// wipe the preset path — the subsequent command still routes direct via it.
|
||||
func TestLoginUsesPresetPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := mustIdentity(t)
|
||||
repeater := mustIdentity(t)
|
||||
shared, _ := repeater.SharedSecret(server.Identity)
|
||||
userPath := []byte{0x11, 0x22, 0x33} // 3 one-byte hops
|
||||
userPathLen := byte(0x03) // size 1, count 3
|
||||
|
||||
var loginRoute byte
|
||||
var loginPath []byte
|
||||
var cmdRoute byte
|
||||
var cmdPath []byte
|
||||
fm := &routeFakeModem{}
|
||||
ex := NewExchanger(fm, server, repeater.Identity, 0, time.Millisecond, 4)
|
||||
ex.SetPath(userPath, userPathLen)
|
||||
fm.reply = func(raw []byte) {
|
||||
pkt, err := meshcore.PacketFromBytes(raw)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch pkt.PayloadType() {
|
||||
case meshcore.PayloadTypeAnonReq:
|
||||
loginRoute = pkt.RouteType()
|
||||
loginPath = append([]byte(nil), pkt.Path...)
|
||||
// Reply as a direct RESPONSE (carries no return path of its own).
|
||||
resp := make([]byte, 13)
|
||||
resp[6] = 1 // admin
|
||||
resp[7] = 3
|
||||
enc, _ := meshcore.EncryptThenMAC(shared, resp)
|
||||
r := &meshcore.Response{Destination: server.Hash()[0], Source: repeater.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]}
|
||||
payload, _ := r.ToBytes()
|
||||
rp := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeDirect, meshcore.PayloadTypeResponse, 0), Payload: payload}
|
||||
b, _ := rp.ToBytes()
|
||||
ex.HandleData(b)
|
||||
case meshcore.PayloadTypeTxtMsg:
|
||||
cmdRoute = pkt.RouteType()
|
||||
cmdPath = append([]byte(nil), pkt.Path...)
|
||||
plain := meshcore.BuildTextPlaintext(time.Unix(1, 0), 1<<2, []byte("> ok"))
|
||||
tm, _ := meshcore.NewTextMessage(repeater, server.Identity, plain, shared)
|
||||
payload, _ := tm.ToBytes()
|
||||
rp := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypeTxtMsg, 0), Payload: payload}
|
||||
b, _ := rp.ToBytes()
|
||||
ex.HandleData(b)
|
||||
}
|
||||
}
|
||||
|
||||
lr, err := ex.Login(context.Background(), "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
if !lr.IsAdmin {
|
||||
t.Fatalf("login not admin")
|
||||
}
|
||||
if loginRoute != meshcore.RouteTypeDirect {
|
||||
t.Errorf("login route = %d, want direct (%d)", loginRoute, meshcore.RouteTypeDirect)
|
||||
}
|
||||
if !bytes.Equal(loginPath, userPath) {
|
||||
t.Errorf("login path = %x, want %x", loginPath, userPath)
|
||||
}
|
||||
if _, err := ex.Command(context.Background(), "get lat", nil); err != nil {
|
||||
t.Fatalf("Command: %v", err)
|
||||
}
|
||||
if cmdRoute != meshcore.RouteTypeDirect || !bytes.Equal(cmdPath, userPath) {
|
||||
t.Errorf("command routed %d path %x, want direct with preset path %x (preset path was wiped)", cmdRoute, cmdPath, userPath)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,17 @@
|
||||
const input = document.getElementById("cmdinput");
|
||||
const sendBtn = document.getElementById("cmdsend");
|
||||
|
||||
// wsURL appends the optional user-entered path (#path) to the base ws URL so
|
||||
// the server routes login/commands directly (with flood fallback).
|
||||
function wsURL() {
|
||||
let url = window.MESHTENDER_WS;
|
||||
const el = document.getElementById("path");
|
||||
if (el && el.value.trim()) {
|
||||
url += (url.indexOf("?") === -1 ? "?" : "&") + "path=" + encodeURIComponent(el.value.trim());
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
if (!("serial" in navigator)) {
|
||||
document.getElementById("unsupported").hidden = false;
|
||||
connectBtn.disabled = true;
|
||||
@@ -83,7 +94,7 @@
|
||||
keepReading = true;
|
||||
addLog("info", "Serial port open. Connecting to server…");
|
||||
|
||||
ws = new WebSocket(window.MESHTENDER_WS);
|
||||
ws = new WebSocket(wsURL());
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
const connectBtn = document.getElementById("connect");
|
||||
const log = document.getElementById("log");
|
||||
|
||||
// wsURL appends the optional user-entered path (#path) to the base ws URL so
|
||||
// the server routes login/commands directly (with flood fallback).
|
||||
function wsURL() {
|
||||
let url = window.MESHTENDER_WS;
|
||||
const el = document.getElementById("path");
|
||||
if (el && el.value.trim()) {
|
||||
url += (url.indexOf("?") === -1 ? "?" : "&") + "path=" + encodeURIComponent(el.value.trim());
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
if (!("serial" in navigator)) {
|
||||
document.getElementById("unsupported").hidden = false;
|
||||
connectBtn.disabled = true;
|
||||
@@ -57,7 +68,7 @@
|
||||
keepReading = true;
|
||||
addLog("info", "Serial port open (115200 8N1). Connecting to server…");
|
||||
|
||||
ws = new WebSocket(window.MESHTENDER_WS);
|
||||
ws = new WebSocket(wsURL());
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
|
||||
Reference in New Issue
Block a user