+
+
+
+
+
+
diff --git a/station_broadcast.lua b/station_broadcast.lua
index 3d7f55a..fb53ba6 100644
--- a/station_broadcast.lua
+++ b/station_broadcast.lua
@@ -153,6 +153,38 @@ function Peripherals.init()
end
end
Peripherals.blockReader = blockReader
+
+ Peripherals.wiredModems = {}
+ pcall(function()
+ local sides = { "back", "top", "bottom", "left", "right", "front" }
+ for _, side in ipairs(sides) do
+ if peripheral.isPresent(side) and peripheral.getType(side) == "modem" then
+ local m = peripheral.wrap(side)
+ if m and not m.isWireless() then
+ table.insert(Peripherals.wiredModems, { side = side, modem = m })
+ pcall(function() rednet.open(side) end)
+ end
+ end
+ end
+ if peripheral.getNames then
+ for _, name in ipairs(peripheral.getNames()) do
+ local already = false
+ for _, wm in ipairs(Peripherals.wiredModems) do
+ if wm.side == name then already = true; break end
+ end
+ if not already and peripheral.getType(name) == "modem" then
+ local m = peripheral.wrap(name)
+ if m and type(m.isWireless) == "function" and not m.isWireless() then
+ table.insert(Peripherals.wiredModems, { side = name, modem = m })
+ pcall(function() rednet.open(name) end)
+ end
+ end
+ end
+ end
+ end)
+ if #Peripherals.wiredModems > 0 then
+ Utils.log("INFO", "Wired modem(s) detected for LocalBus: x" .. tostring(#Peripherals.wiredModems))
+ end
end
@@ -203,7 +235,17 @@ function Utils.loadStationIdentity()
return data
end
-function Utils.saveStationIdentity(stationCode, stationPlatform, departureBeep)
+function Utils.saveStationIdentity(stationCode, stationPlatform, departureBeep, cableGroupOverride)
+ local existing = Utils.loadStationIdentity() or {}
+ local cableGroup = cableGroupOverride
+ if cableGroup == nil then cableGroup = existing.audio_cable_group or "" end
+ if type(stationCode) == "table" then
+ local tbl = stationCode
+ stationCode = tbl.station_code or tbl.stationCode
+ stationPlatform = tbl.station_platform or tbl.stationPlatform
+ departureBeep = tbl.departure_beep or tbl.departureBeep
+ cableGroup = tbl.audio_cable_group or tbl.cableGroup or cableGroup
+ end
local handle = fs.open(CONFIG.STATION_IDENTITY_FILE, "w")
if not handle then
return false
@@ -213,6 +255,7 @@ function Utils.saveStationIdentity(stationCode, stationPlatform, departureBeep)
"station_code=" .. Utils.trim(stationCode or ""),
"station_platform=" .. Utils.trim(stationPlatform or ""),
"departure_beep=" .. Utils.trim(departureBeep or "fc1"),
+ "audio_cable_group=" .. Utils.trim(cableGroup or ""),
""
}, "\n"))
handle.close()
@@ -555,6 +598,15 @@ function Utils.refreshStationIdentity()
State.stationPlatform = identity.stationPlatform
State.departureBeep = identity.departureBeep
State.stationIdentityMode = identity.mode
+ local manual = Utils.loadStationIdentity() or {}
+ State.localCableGroup = Utils.trim(manual.audio_cable_group or "")
+end
+
+function Utils.isLocalStationMaster()
+ if not LocalBus or not LocalBus.stationGroup or not LocalBus.stationGroup.masterNodeId then
+ return true
+ end
+ return LocalBus.stationGroup.masterNodeId == LocalBus.nodeId
end
function Utils.getStationCode(name)
@@ -1184,6 +1236,13 @@ function Audio.stop(mode, flushBelow)
State.audioQueue = kept
end
+ if LocalBus and LocalBus.tokenHolder then
+ local cg = LocalBus.tokenHolder.cableGroup
+ local sn = LocalBus.tokenHolder.stationName
+ local aid = LocalBus.tokenHolder.audioId
+ LocalBus.releaseLocalToken(cg, sn, aid)
+ end
+
os.queueEvent("audio_interrupt", State.interruptSeq)
end
@@ -2017,16 +2076,38 @@ function Audio.playerLoop()
os.sleep(0.05)
else
local item = table.remove(State.audioQueue, 1)
- State.currentPlaying = item
- State.currentPriority = tonumber(item.priority) or 0
-
- local ok, err = pcall(Audio.playItem, item)
- if not ok then
- Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err))
+ local cableGroup = tostring(State.localCableGroup or "")
+ local audioId = tostring(item.audioId or ("local-" .. tostring(os.clock())))
+ local priority = tonumber(item.priority) or 0
+
+ local tokenOk = true
+ if LocalBus and LocalBus.enabled and cableGroup ~= "" then
+ tokenOk = LocalBus.acquireLocalToken(cableGroup, State.stationName, audioId, priority, 1500)
+ end
+
+ if not tokenOk then
+ Utils.log("WARN", "Audio playback skipped: local cable token denied for audioId=" .. audioId)
+ State.currentPlaying = nil
+ State.currentPriority = -1
+ if LocalBus and LocalBus.tokenHolder then
+ LocalBus.releaseLocalToken(cableGroup, State.stationName, audioId)
+ end
+ else
+ State.currentPlaying = item
+ State.currentPriority = priority
+
+ local ok, err = pcall(Audio.playItem, item)
+ if not ok then
+ Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err))
+ end
+
+ State.currentPlaying = nil
+ State.currentPriority = -1
+
+ if LocalBus and LocalBus.tokenHolder then
+ LocalBus.releaseLocalToken(cableGroup, State.stationName, audioId)
+ end
end
-
- State.currentPlaying = nil
- State.currentPriority = -1
end
end
end
@@ -2249,6 +2330,381 @@ function AutoUpdate.checkOnBoot()
return ok
end
+-- ============================================================================
+-- 5.5 LocalBus (Wired Modem / RedNet local arbitration & station group)
+-- ============================================================================
+local LocalBus = {}
+LocalBus.nodeId = tostring(os.getComputerID() or math.floor(math.random() * 1e9))
+LocalBus.enabled = false
+LocalBus.neighbors = {}
+LocalBus.stationGroup = { members = {}, masterNodeId = nil, groupKey = nil }
+LocalBus.tokenHolder = nil
+LocalBus.tokenQueue = {}
+LocalBus.lastHelloSentAt = 0
+LocalBus.lastPlayingAt = 0
+LocalBus.pendingProbeResponses = {}
+
+function LocalBus._hasWiredModem()
+ return Peripherals.wiredModems and #Peripherals.wiredModems > 0
+end
+
+function LocalBus._broadcast(msg)
+ if not msg or type(msg) ~= "table" then return end
+ msg.nodeId = LocalBus.nodeId
+ msg.ts = msg.ts or os.epoch("utc")
+ local raw = textutils.serialize(msg)
+ if LocalBus._hasWiredModem() then
+ pcall(function()
+ if rednet then rednet.broadcast(raw, "station_bus") end
+ end)
+ for _, wm in ipairs(Peripherals.wiredModems) do
+ pcall(function()
+ if wm.modem and type(wm.modem.transmit) == "function" then
+ wm.modem.transmit(7420, 7420, raw)
+ end
+ end)
+ end
+ end
+end
+
+function LocalBus.init()
+ LocalBus.enabled = LocalBus._hasWiredModem()
+ Utils.refreshStationIdentity()
+ LocalBus.stationGroup = {
+ members = { { nodeId = LocalBus.nodeId, self = true, stationName = State.stationName, hasStationPeripheral = State.hasStationPeripheral } },
+ masterNodeId = LocalBus.nodeId,
+ groupKey = nil
+ }
+ if LocalBus.enabled then
+ Utils.log("INFO", "LocalBus enabled via wired modem(s): nodeId=" .. LocalBus.nodeId .. " cableGroup=" .. tostring(State.localCableGroup or "(none)"))
+ end
+end
+
+function LocalBus._sendHello()
+ LocalBus._broadcast({
+ type = "station_hello",
+ stationName = State.stationName,
+ stationCode = State.stationCode,
+ stationPlatform = State.stationPlatform,
+ cableGroup = tostring(State.localCableGroup or ""),
+ hasStationPeripheral = not not State.hasStationPeripheral,
+ isMaster = Utils.isLocalStationMaster()
+ })
+end
+
+function LocalBus._cleanupNeighbors(now)
+ local cutoff = (now or os.epoch("utc")) - 30000
+ local changed = false
+ for nid, nb in pairs(LocalBus.neighbors) do
+ if (nb.ts or 0) < cutoff then
+ LocalBus.neighbors[nid] = nil
+ changed = true
+ end
+ end
+ if changed then LocalBus._rebuildStationGroup() end
+end
+
+function LocalBus._rebuildStationGroup()
+ local myName = Utils.trim(State.stationName or "")
+ local members = { {
+ nodeId = LocalBus.nodeId,
+ self = true,
+ stationName = myName,
+ hasStationPeripheral = not not State.hasStationPeripheral,
+ cableGroup = tostring(State.localCableGroup or ""),
+ lastSeen = os.epoch("utc")
+ } }
+ for _, nb in pairs(LocalBus.neighbors) do
+ if nb and type(nb) == "table" and Utils.trim(nb.stationName or "") == myName then
+ table.insert(members, {
+ nodeId = nb.nodeId,
+ self = false,
+ stationName = nb.stationName,
+ hasStationPeripheral = not not nb.hasStationPeripheral,
+ cableGroup = nb.cableGroup or "",
+ lastSeen = nb.ts or 0
+ })
+ end
+ end
+ table.sort(members, function(a, b)
+ if a.hasStationPeripheral ~= b.hasStationPeripheral then
+ return a.hasStationPeripheral and true or false
+ end
+ return tostring(a.nodeId) < tostring(b.nodeId)
+ end)
+ local master = members[1]
+ LocalBus.stationGroup = {
+ members = members,
+ masterNodeId = master and master.nodeId or LocalBus.nodeId,
+ groupKey = myName,
+ memberCount = #members
+ }
+end
+
+function LocalBus._sameStation(msg)
+ if not msg or not msg.stationName then return false end
+ local a = Utils.trim(State.stationName or "")
+ local b = Utils.trim(msg.stationName or "")
+ return a ~= "" and a == b
+end
+
+function LocalBus._sameGroup(msg)
+ if not msg then return false end
+ local a = Utils.trim(State.localCableGroup or "")
+ local b = Utils.trim(msg.cableGroup or "")
+ if a == "" or b == "" then return false end
+ return a == b
+end
+
+function LocalBus.releaseLocalToken(cableGroup, stationName, audioId)
+ if not LocalBus.tokenHolder then return end
+ local match = true
+ if cableGroup and cableGroup ~= "" and LocalBus.tokenHolder.cableGroup ~= cableGroup then match = false end
+ if audioId and LocalBus.tokenHolder.audioId and LocalBus.tokenHolder.audioId ~= audioId then match = false end
+ if match then
+ LocalBus._broadcast({
+ type = "audio_release",
+ stationName = State.stationName,
+ cableGroup = LocalBus.tokenHolder.cableGroup,
+ audioId = LocalBus.tokenHolder.audioId
+ })
+ LocalBus.tokenHolder = nil
+ end
+end
+
+function LocalBus._sendPlayingHeartbeat(force)
+ if not LocalBus.tokenHolder then return end
+ local now = os.epoch("utc")
+ if force or (now - LocalBus.lastPlayingAt) >= 2000 then
+ LocalBus._broadcast({
+ type = "audio_playing",
+ stationName = State.stationName,
+ cableGroup = LocalBus.tokenHolder.cableGroup,
+ audioId = LocalBus.tokenHolder.audioId,
+ priority = LocalBus.tokenHolder.priority,
+ startedAt = LocalBus.tokenHolder.startedAt
+ })
+ LocalBus.lastPlayingAt = now
+ end
+end
+
+function LocalBus._collectProbeResponses(cableGroup, audioId, waitMs)
+ LocalBus.pendingProbeResponses = { playing = {}, requests = {} }
+ local started = os.clock()
+ local timeout = started + (waitMs / 1000)
+ while os.clock() < timeout do
+ local sid, msg, proto
+ if rednet then
+ pcall(function() sid, msg, proto = rednet.receive("station_bus", 0.02) end)
+ end
+ if type(msg) == "string" then
+ local ok, tbl = pcall(textutils.unserialize, msg)
+ if ok and type(tbl) == "table" then LocalBus._handleIncoming(tbl) end
+ end
+ os.sleep(0.01)
+ end
+ return LocalBus.pendingProbeResponses
+end
+
+function LocalBus._handleIncoming(tbl)
+ if not tbl or type(tbl) ~= "table" or not tbl.type then return end
+ if tbl.nodeId == LocalBus.nodeId then return end
+ if tbl.type == "station_hello" then
+ LocalBus.neighbors[tostring(tbl.nodeId)] = tbl
+ LocalBus._rebuildStationGroup()
+ elseif tbl.type == "audio_probe" or tbl.type == "audio_playing" or tbl.type == "audio_token_grant" then
+ if LocalBus._sameGroup(tbl) or LocalBus._sameStation(tbl) then
+ if tbl.type == "audio_playing" then
+ table.insert(LocalBus.pendingProbeResponses.playing, tbl)
+ elseif tbl.type == "audio_probe" then
+ table.insert(LocalBus.pendingProbeResponses.requests, tbl)
+ if LocalBus.tokenHolder then
+ LocalBus._broadcast({
+ type = "audio_playing",
+ stationName = State.stationName,
+ cableGroup = LocalBus.tokenHolder.cableGroup,
+ audioId = LocalBus.tokenHolder.audioId,
+ priority = LocalBus.tokenHolder.priority,
+ startedAt = LocalBus.tokenHolder.startedAt,
+ targetProbeId = tostring(tbl.nodeId)
+ })
+ end
+ end
+ end
+ elseif tbl.type == "audio_preempt" then
+ if tostring(tbl.targetNodeId or "") == LocalBus.nodeId and LocalBus.tokenHolder then
+ local newP = tonumber(tbl.newPriority) or 0
+ Utils.log("WARN", "LocalBus audio preempted by node " .. tostring(tbl.nodeId) .. " reason=" .. tostring(tbl.reason or ""))
+ Audio.stop("current", newP)
+ LocalBus.releaseLocalToken(nil, nil, nil)
+ end
+ elseif tbl.type == "audio_release" then
+ if LocalBus._sameGroup(tbl) then
+ LocalBus.tokenQueue = {}
+ end
+ end
+end
+
+function LocalBus.acquireLocalToken(cableGroup, stationName, audioId, priority, timeoutMs)
+ if not LocalBus.enabled then return true end
+ cableGroup = Utils.trim(cableGroup or "")
+ priority = tonumber(priority) or 0
+ if cableGroup == "" then return true end
+
+ local attempts = 0
+ while attempts < 2 do
+ attempts = attempts + 1
+ LocalBus._broadcast({
+ type = "audio_probe",
+ stationName = stationName or State.stationName,
+ cableGroup = cableGroup,
+ audioId = audioId,
+ priority = priority
+ })
+ local responses = LocalBus._collectProbeResponses(cableGroup, audioId, 80)
+ local conflictHolder = nil
+ for _, p in ipairs(responses.playing or {}) do
+ local holderPrio = tonumber(p.priority) or 0
+ if holderPrio >= priority then
+ if not conflictHolder or holderPrio > (tonumber(conflictHolder.priority) or 0) then
+ conflictHolder = p
+ end
+ end
+ end
+ for _, r in ipairs(responses.requests or {}) do
+ if LocalBus._sameStation(r) then
+ local rPrio = tonumber(r.priority) or 0
+ local myId = tostring(LocalBus.nodeId)
+ local otherId = tostring(r.nodeId)
+ if rPrio >= priority and otherId < myId then
+ conflictHolder = r
+ end
+ end
+ end
+ if not conflictHolder then
+ LocalBus.tokenHolder = {
+ cableGroup = cableGroup,
+ stationName = stationName or State.stationName,
+ audioId = audioId,
+ priority = priority,
+ startedAt = os.epoch("utc"),
+ attempts = attempts
+ }
+ LocalBus._broadcast({
+ type = "audio_token_grant",
+ requesterNodeId = LocalBus.nodeId,
+ stationName = State.stationName,
+ cableGroup = cableGroup,
+ audioId = audioId,
+ priority = priority
+ })
+ LocalBus._sendPlayingHeartbeat(true)
+ return true
+ else
+ local holderPrio = tonumber(conflictHolder.priority) or 0
+ if priority > holderPrio then
+ LocalBus._broadcast({
+ type = "audio_preempt",
+ targetNodeId = tostring(conflictHolder.nodeId),
+ stationName = State.stationName,
+ cableGroup = cableGroup,
+ audioId = audioId,
+ reason = "higher_priority",
+ newPriority = priority
+ })
+ os.sleep(0.15)
+ else
+ local waited = 0
+ while waited < (timeoutMs or 1500) / 1000 do
+ if not LocalBus.tokenHolder then break end
+ os.sleep(0.05)
+ waited = waited + 0.05
+ end
+ if not LocalBus.tokenHolder then break end
+ end
+ end
+ end
+ if attempts >= 2 then
+ Utils.log("WARN", "LocalBus token acquire failed: cableGroup=" .. cableGroup .. " audioId=" .. tostring(audioId))
+ return false
+ end
+ LocalBus.tokenHolder = {
+ cableGroup = cableGroup,
+ stationName = stationName or State.stationName,
+ audioId = audioId,
+ priority = priority,
+ startedAt = os.epoch("utc"),
+ attempts = attempts
+ }
+ LocalBus._sendPlayingHeartbeat(true)
+ return true
+end
+
+function LocalBus._cleanupExpiredTokens(now)
+ now = now or os.epoch("utc")
+ if LocalBus.tokenHolder then
+ local elapsed = now - (LocalBus.tokenHolder.startedAt or now)
+ if elapsed > 30000 then
+ Utils.log("WARN", "LocalBus token expired (30s), releasing: " .. tostring(LocalBus.tokenHolder.audioId))
+ LocalBus.releaseLocalToken(nil, nil, nil)
+ end
+ end
+end
+
+function LocalBus._pullOnce(timeoutSec)
+ timeoutSec = timeoutSec or 0.02
+ if LocalBus.enabled and rednet then
+ local sid, msg, proto
+ pcall(function() sid, msg, proto = rednet.receive("station_bus", timeoutSec) end)
+ if type(msg) == "string" then
+ local ok, tbl = pcall(textutils.unserialize, msg)
+ if ok and type(tbl) == "table" then
+ LocalBus._handleIncoming(tbl)
+ return true
+ end
+ end
+ end
+ for _, wm in ipairs(Peripherals.wiredModems or {}) do
+ local got, side, ch, repCh, payload
+ pcall(function()
+ if wm.modem and type(os.pullEvent) == "function" then
+ local ev = { os.pullEvent("modem_message", timeoutSec / 2) }
+ if ev and ev[1] == "modem_message" then
+ side, ch, repCh, payload = ev[2], ev[3], ev[4], ev[5]
+ got = (side == wm.side and ch == 7420)
+ end
+ end
+ end)
+ if got and type(payload) == "string" then
+ local ok2, tbl2 = pcall(textutils.unserialize, payload)
+ if ok2 and type(tbl2) == "table" then
+ LocalBus._handleIncoming(tbl2)
+ return true
+ end
+ end
+ end
+ return false
+end
+
+function LocalBus.loop()
+ LocalBus.init()
+ LocalBus._sendHello()
+ LocalBus.lastHelloSentAt = os.epoch("utc")
+
+ while true do
+ local now = os.epoch("utc")
+ if (now - LocalBus.lastHelloSentAt) >= 10000 then
+ LocalBus._sendHello()
+ LocalBus.lastHelloSentAt = now
+ end
+ LocalBus._cleanupNeighbors(now)
+ LocalBus._cleanupExpiredTokens(now)
+ LocalBus._sendPlayingHeartbeat(false)
+ pcall(function() LocalBus._pullOnce(0.05) end)
+ os.sleep(0.05)
+ end
+end
+
-- ============================================================================
-- 6. Network System
-- ============================================================================
@@ -2285,7 +2741,8 @@ function Network.register()
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform,
- stationVersion = APP_VERSION_TEXT
+ stationVersion = APP_VERSION_TEXT,
+ cableGroup = tostring(State.localCableGroup or "")
})
end
@@ -2449,6 +2906,22 @@ function Network.messageLoop()
Utils.log("ERROR", "Station update failed: " .. tostring(updateErr))
end
end
+ elseif data.type == "audio_preempt" then
+ local flushBelow = tonumber(data.priority) or 0
+ local mode = tostring(data.mode or "current")
+ local reason = tostring(data.reason or "server_preempt")
+ Utils.log("WARN", "Audio preempted by server: reason=" .. reason .. " priority>=" .. tostring(flushBelow))
+ Audio.stop(mode, flushBelow)
+ if LocalBus and LocalBus.tokenHolder then
+ LocalBus.releaseLocalToken(nil, nil, nil)
+ end
+ elseif data.type == "cable_token_denied" then
+ Utils.log("WARN", "Server cable token denied: group=" .. tostring(data.cableGroupId or "") .. " holder=" .. tostring(data.holderStationName or "") .. " reason=" .. tostring(data.reason or ""))
+ elseif data.type == "station_group_state" then
+ if data.masterNodeId and LocalBus and not LocalBus.enabled then
+ LocalBus.stationGroup = LocalBus.stationGroup or {}
+ LocalBus.stationGroup.masterNodeId = tostring(data.masterNodeId)
+ end
end
else
if State.audioRx then
@@ -2530,7 +3003,9 @@ function Network.heartbeat()
identityMode = State.stationIdentityMode,
upcomingTrains = upcomingSummary,
currentTrain = currentTrainInfo,
- upcomingCount = #(State.upcomingTrains or {})
+ upcomingCount = #(State.upcomingTrains or {}),
+ cableGroup = tostring(State.localCableGroup or ""),
+ tickRate = State.tickRate
})
end
os.sleep(10)