Added websocket connectivity and improved some things.

This commit is contained in:
Andrew Lalis 2022-05-13 13:24:13 +02:00
parent 35c13d83bd
commit 08fb892cc5
42 changed files with 672 additions and 156 deletions

View File

@ -29,7 +29,7 @@
</ul>
</div>
<div class="mb-3">
<label for="addSwitchConfigs" class="form-label">Segment</label>
<label for="addSwitchConfigs" class="form-label">Select two nodes this switch can connect.</label>
<select id="addSwitchConfigs" class="form-select" multiple v-model="formData.possibleConfigQueue">
<option v-for="node in getEligibleNodes()" :key="node.id" :value="node">
{{node.name}}

View File

@ -28,6 +28,7 @@
</table>
<SignalComponentView v-if="component.type === 'SIGNAL'" :signal="component" />
<SegmentBoundaryNodeComponentView v-if="component.type === 'SEGMENT_BOUNDARY'" :node="component" />
<SwitchComponentView v-if="component.type === 'SWITCH'" :sw="component"/>
<PathNodeComponentView v-if="component.connectedNodes" :pathNode="component" :railSystem="railSystem" />
<button @click="removeComponent()" class="btn btn-sm btn-danger">Remove</button>
</div>
@ -45,9 +46,11 @@ import PathNodeComponentView from "./PathNodeComponentView.vue";
import SegmentBoundaryNodeComponentView from "./SegmentBoundaryNodeComponentView.vue";
import {useRailSystemsStore} from "../../../stores/railSystemsStore";
import ConfirmModal from "../../ConfirmModal.vue";
import SwitchComponentView from "./SwitchComponentView.vue";
export default {
components: {
SwitchComponentView,
ConfirmModal,
SegmentBoundaryNodeComponentView,
SignalComponentView,

View File

@ -1,40 +1,32 @@
<template>
<h5>Connected Nodes</h5>
<table class="table" v-if="pathNode.connectedNodes.length > 0">
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr v-for="node in pathNode.connectedNodes" :key="node.id">
<td>{{node.name}}</td>
<td>
<button
@click="rsStore.removeConnection(pathNode, node)"
class="btn btn-sm btn-danger"
>
Remove
</button>
</td>
</tr>
</tbody>
</table>
<p v-if="pathNode.connectedNodes.length === 0">
There are no connected nodes.
</p>
<form
@submit.prevent="rsStore.addConnection(pathNode, formData.nodeToAdd)"
v-if="getEligibleConnections().length > 0"
class="input-group mb-3"
>
<select v-model="formData.nodeToAdd" class="form-select form-select-sm">
<option v-for="node in this.getEligibleConnections()" :key="node.id" :value="node">
<h5>Connected Nodes</h5>
<ul class="list-group list-group-flush mb-2 border" v-if="pathNode.connectedNodes.length > 0" style="overflow: auto; max-height: 150px;">
<li
v-for="node in pathNode.connectedNodes"
:key="node.id"
class="list-group-item"
>
{{node.name}}
</option>
</select>
<button type="submit" class="btn btn-sm btn-success">Add Connection</button>
</form>
<button @click="rsStore.removeConnection(pathNode, node)" class="btn btn-sm btn-danger float-end">
Remove
</button>
</li>
</ul>
<p v-if="pathNode.connectedNodes.length === 0">
There are no connected nodes.
</p>
<form
@submit.prevent="rsStore.addConnection(pathNode, formData.nodeToAdd)"
v-if="getEligibleConnections().length > 0"
class="input-group mb-3"
>
<select v-model="formData.nodeToAdd" class="form-select form-select-sm">
<option v-for="node in this.getEligibleConnections()" :key="node.id" :value="node">
{{node.name}}
</option>
</select>
<button type="submit" class="btn btn-sm btn-success">Add Connection</button>
</form>
</template>
<script>

View File

@ -4,11 +4,13 @@
<thead>
<tr>
<th>Name</th>
<th>Occupied</th>
</tr>
</thead>
<tbody>
<tr v-for="segment in node.segments" :key="segment.id">
<td>{{segment.name}}</td>
<td>{{segment.occupied}}</td>
</tr>
</tbody>
</table>

View File

@ -4,7 +4,7 @@
<tbody>
<tr>
<th>Connected to</th>
<td>{{signal.segment.name}}</td>
<td>{{signal.segment.name}}, Occupied: {{signal.segment.occupied}}</td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,40 @@
<template>
<h5>Switch Configurations</h5>
<ul class="list-group list-group-flush border" v-if="sw.possibleConfigurations.length > 0" style="overflow: auto; max-height: 150px;">
<li
v-for="config in sw.possibleConfigurations"
:key="config.id"
class="list-group-item"
>
<span
v-for="node in config.nodes"
:key="node.id"
class="badge bg-secondary me-1"
>
{{node.name}}
</span>
<span
v-if="sw.activeConfiguration !== null && sw.activeConfiguration.id === config.id"
class="badge bg-success"
>
Active
</span>
</li>
</ul>
</template>
<script>
export default {
name: "SwitchComponentView",
props: {
sw: {
type: Object,
required: true
}
}
}
</script>
<style scoped>
</style>

View File

@ -80,15 +80,15 @@ export const useRailSystemsStore = defineStore('RailSystemsStore', {
this.websocket.close();
}
console.log(this.wsUrl);
this.websocket = new WebSocket(this.wsUrl);
this.websocket = new WebSocket(this.wsUrl + "/" + this.selectedRailSystem.id);
this.websocket.onopen = event => {
console.log("Opened websocket connection.");
};
this.websocket.onclose = event => {
console.log("Closed websocket connection.");
};
this.websocket.onmessage = () => {
this.websocket.onmessage = (msg) => {
console.log(msg);
};
},
addSegment(name) {

View File

@ -2,8 +2,11 @@ package nl.andrewl.railsignalapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
@SpringBootApplication
@SpringBootApplication(exclude = {
UserDetailsServiceAutoConfiguration.class
})
public class RailSignalApiApplication {
public static void main(String[] args) {

View File

@ -1,12 +0,0 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.ComponentAccessToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ComponentAccessTokenRepository extends JpaRepository<ComponentAccessToken, Long> {
Iterable<ComponentAccessToken> findAllByTokenPrefix(String prefix);
boolean existsByLabel(String label);
}

View File

@ -1,6 +1,6 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.Label;
import nl.andrewl.railsignalapi.model.component.Label;
import nl.andrewl.railsignalapi.model.RailSystem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

View File

@ -0,0 +1,12 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.LinkToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface LinkTokenRepository extends JpaRepository<LinkToken, Long> {
Iterable<LinkToken> findAllByTokenPrefix(String prefix);
boolean existsByLabel(String label);
}

View File

@ -2,23 +2,27 @@ package nl.andrewl.railsignalapi.live;
import lombok.Getter;
/**
* A downlink connection to one or more components (linked by a {@link nl.andrewl.railsignalapi.model.LinkToken}
* which we can send messages to.
*/
public abstract class ComponentDownlink {
@Getter
private final long id;
private final long tokenId;
public ComponentDownlink(long id) {
this.id = id;
public ComponentDownlink(long tokenId) {
this.tokenId = tokenId;
}
public abstract void send(Object msg) throws Exception;
@Override
public boolean equals(Object o) {
return o instanceof ComponentDownlink cd && cd.id == this.id;
return o instanceof ComponentDownlink cd && cd.tokenId == this.tokenId;
}
@Override
public int hashCode() {
return Long.hashCode(id);
return Long.hashCode(tokenId);
}
}

View File

@ -1,34 +1,91 @@
package nl.andrewl.railsignalapi.live;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.dao.LinkTokenRepository;
import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.model.component.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;
/**
* A service that manages all the active component downlink connections.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ComponentDownlinkService {
private final Map<ComponentDownlink, Set<Long>> componentDownlinks = new HashMap<>();
private final Map<Long, Set<ComponentDownlink>> downlinksByCId = new HashMap<>();
public synchronized void registerDownlink(ComponentDownlink downlink, Set<Long> componentIds) {
componentDownlinks.put(downlink, componentIds);
private final LinkTokenRepository tokenRepository;
private final ComponentRepository<Component> componentRepository;
/**
* Registers a new active downlink to one or more components.
* @param downlink The downlink to register.
*/
@Transactional
public synchronized void registerDownlink(ComponentDownlink downlink) {
Set<Component> components = tokenRepository.findById(downlink.getTokenId()).orElseThrow().getComponents();
componentDownlinks.put(downlink, components.stream().map(Component::getId).collect(Collectors.toSet()));
for (var c : components) {
c.setOnline(true);
Set<ComponentDownlink> downlinks = downlinksByCId.computeIfAbsent(c.getId(), aLong -> new HashSet<>());
downlinks.add(downlink);
}
componentRepository.saveAll(components);
log.info("Registered downlink with token id {}.", downlink.getTokenId());
}
/**
* De-registers a downlink to components. This should be called when this
* downlink is closed.
* @param downlink The downlink to de-register.
*/
@Transactional
public synchronized void deregisterDownlink(ComponentDownlink downlink) {
componentDownlinks.remove(downlink);
Set<Long> componentIds = componentDownlinks.remove(downlink);
if (componentIds != null) {
for (var cId : componentIds) {
componentRepository.findById(cId).ifPresent(component -> {
component.setOnline(false);
componentRepository.save(component);
});
Set<ComponentDownlink> downlinks = downlinksByCId.get(cId);
if (downlinks != null) {
downlinks.remove(downlink);
if (downlinks.isEmpty()) {
downlinksByCId.remove(cId);
}
}
}
}
log.info("De-registered downlink with token id {}.", downlink.getTokenId());
}
@Transactional
public synchronized void deregisterDownlink(long tokenId) {
List<ComponentDownlink> removeSet = componentDownlinks.keySet().stream()
.filter(downlink -> downlink.getId() == tokenId).toList();
.filter(downlink -> downlink.getTokenId() == tokenId).toList();
for (var downlink : removeSet) {
componentDownlinks.remove(downlink);
deregisterDownlink(downlink);
}
}
public void sendMessage(long componentId, Object msg) {
var downlinks = downlinksByCId.get(componentId);
if (downlinks != null) {
for (var downlink : downlinks) {
try {
downlink.send(msg);
} catch (Exception e) {
log.warn("An error occurred while sending a message to downlink with token id " + downlink.getTokenId(), e);
}
}
}
}
}

View File

@ -0,0 +1,32 @@
package nl.andrewl.railsignalapi.live;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.live.dto.ComponentUplinkMessage;
import nl.andrewl.railsignalapi.live.dto.SegmentBoundaryUpdateMessage;
import nl.andrewl.railsignalapi.live.dto.SwitchUpdateMessage;
import nl.andrewl.railsignalapi.service.SegmentService;
import nl.andrewl.railsignalapi.service.SwitchService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* A central service that manages all incoming component messages from any
* connected component links.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ComponentUplinkMessageHandler {
private final SwitchService switchService;
private final SegmentService segmentService;
@Transactional
public void messageReceived(ComponentUplinkMessage msg) {
if (msg instanceof SegmentBoundaryUpdateMessage sb) {
segmentService.onBoundaryUpdate(sb);
} else if (msg instanceof SwitchUpdateMessage sw) {
switchService.onSwitchUpdate(sw);
}
}
}

View File

@ -0,0 +1,20 @@
package nl.andrewl.railsignalapi.live.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
/**
* The parent class for all uplink messages that can be sent by connected
* components.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true)
@JsonSubTypes({
@JsonSubTypes.Type(value = SegmentBoundaryUpdateMessage.class, name = "sb"),
@JsonSubTypes.Type(value = SwitchUpdateMessage.class, name = "sw")
})
public abstract class ComponentUplinkMessage {
public long cId;
public String type;
}

View File

@ -0,0 +1,30 @@
package nl.andrewl.railsignalapi.live.dto;
/**
* Message that's sent by segment boundaries when a train crosses it.
*/
public class SegmentBoundaryUpdateMessage extends ComponentUplinkMessage {
/**
* The id of the segment that a train detected by the segment boundary is
* moving towards.
*/
public long toSegmentId;
/**
* The type of boundary crossing event.
*/
public Type eventType;
public enum Type {
/**
* Used when a train first begins to enter a segment, which means the
* train is now transitioning from its previous to next segment.
*/
ENTERING,
/**
* Used when a train has completely entered a segment, which means it
* is completely out of its previous segment.
*/
ENTERED
}
}

View File

@ -0,0 +1,6 @@
package nl.andrewl.railsignalapi.live.dto;
public record SegmentStatusMessage (
long cId,
boolean occupied
) {}

View File

@ -0,0 +1,13 @@
package nl.andrewl.railsignalapi.live.dto;
import java.util.Set;
/**
* Message that's sent by a switch when its active configuration is updated.
*/
public class SwitchUpdateMessage extends ComponentUplinkMessage {
/**
* A set of path node ids that represents the active configuration.
*/
public Set<Long> configuration;
}

View File

@ -0,0 +1,6 @@
package nl.andrewl.railsignalapi.live.tcp_socket;
public record ConnectMessage(
boolean valid,
String message
) {}

View File

@ -1,24 +0,0 @@
package nl.andrewl.railsignalapi.live.tcp_socket;
import nl.andrewl.railsignalapi.live.ComponentDownlink;
import nl.andrewl.railsignalapi.util.JsonUtils;
import java.io.DataOutputStream;
import java.nio.charset.StandardCharsets;
public class TcpDownlink extends ComponentDownlink {
private final DataOutputStream out;
public TcpDownlink(long id, DataOutputStream out) {
super(id);
this.out = out;
}
@Override
public void send(Object msg) throws Exception {
byte[] jsonBytes = JsonUtils.toJson(msg).getBytes(StandardCharsets.UTF_8);
out.writeInt(jsonBytes.length);
out.write(jsonBytes);
out.flush();
}
}

View File

@ -0,0 +1,70 @@
package nl.andrewl.railsignalapi.live.tcp_socket;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.live.ComponentDownlink;
import nl.andrewl.railsignalapi.live.ComponentDownlinkService;
import nl.andrewl.railsignalapi.live.ComponentUplinkMessageHandler;
import nl.andrewl.railsignalapi.live.dto.ComponentUplinkMessage;
import nl.andrewl.railsignalapi.util.JsonUtils;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
/**
* This link manager is started when a TCP link is established.
*/
@Slf4j
public class TcpLinkManager extends ComponentDownlink implements Runnable {
private final Socket socket;
private final ComponentDownlinkService downlinkService;
private final ComponentUplinkMessageHandler uplinkMessageHandler;
private final DataOutputStream out;
private final DataInputStream in;
public TcpLinkManager(
long tokenId,
Socket socket,
ComponentDownlinkService downlinkService,
ComponentUplinkMessageHandler uplinkMessageHandler
) throws IOException {
super(tokenId);
this.socket = socket;
this.downlinkService = downlinkService;
this.uplinkMessageHandler = uplinkMessageHandler;
this.out = new DataOutputStream(socket.getOutputStream());
this.in = new DataInputStream(socket.getInputStream());
}
@Override
public void run() {
downlinkService.registerDownlink(this);
while (!socket.isClosed()) {
try {
var msg = JsonUtils.readMessage(in, ComponentUplinkMessage.class);
uplinkMessageHandler.messageReceived(msg);
} catch (IOException e) {
log.warn("An error occurred while receiving an uplink message.", e);
}
}
downlinkService.deregisterDownlink(this);
}
public void shutdown() {
try {
this.socket.close();
} catch (IOException e) {
log.warn("An error occurred while closing TCP socket.", e);
}
}
@Override
public void send(Object msg) throws Exception {
synchronized (out) {
JsonUtils.writeJsonString(out, msg);
}
}
}

View File

@ -1,8 +1,10 @@
package nl.andrewl.railsignalapi.live.tcp_socket;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.dao.ComponentAccessTokenRepository;
import nl.andrewl.railsignalapi.model.ComponentAccessToken;
import nl.andrewl.railsignalapi.dao.LinkTokenRepository;
import nl.andrewl.railsignalapi.live.ComponentDownlinkService;
import nl.andrewl.railsignalapi.live.ComponentUplinkMessageHandler;
import nl.andrewl.railsignalapi.model.LinkToken;
import nl.andrewl.railsignalapi.util.JsonUtils;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.ContextClosedEvent;
@ -16,23 +18,48 @@ import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.HashSet;
import java.util.Set;
/**
* A plain TCP server socket which can be used to connect to components that
* don't have access to a full websocket client implementation.
* don't have access to a full websocket client implementation. Instead of the
* standard interceptor -> handler workflow for incoming connections, this
* server simply lets the component link send its token as an initial packet
* in the socket.
* <p>
* All messages sent in this TCP socket are formatted as length-prefixed
* JSON messages, where a 2-byte length is sent, followed by exactly that
* many bytes, which can be parsed as a JSON object.
* </p>
* <p>
* In response to the connection packet, the server will send a
* {@link ConnectMessage} response.
* </p>
*/
@Component
@Slf4j
public class TcpSocketServer {
private final ServerSocket serverSocket;
private final ComponentAccessTokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder;
private final Set<TcpLinkManager> linkManagers;
public TcpSocketServer(ComponentAccessTokenRepository tokenRepository, PasswordEncoder passwordEncoder) throws IOException {
private final LinkTokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder;
private final ComponentDownlinkService componentDownlinkService;
private final ComponentUplinkMessageHandler uplinkMessageHandler;
public TcpSocketServer(
LinkTokenRepository tokenRepository,
PasswordEncoder passwordEncoder,
ComponentDownlinkService componentDownlinkService,
ComponentUplinkMessageHandler uplinkMessageHandler
) throws IOException {
this.tokenRepository = tokenRepository;
this.passwordEncoder = passwordEncoder;
this.componentDownlinkService = componentDownlinkService;
this.uplinkMessageHandler = uplinkMessageHandler;
this.linkManagers = new HashSet<>();
this.serverSocket = new ServerSocket();
serverSocket.setReuseAddress(true);
serverSocket.bind(new InetSocketAddress("localhost", 8081));
@ -47,33 +74,42 @@ public class TcpSocketServer {
Socket socket = serverSocket.accept();
initializeConnection(socket);
} catch (IOException e) {
log.warn("An IOException occurred while waiting to accept a TCP socket connection.", e);
if (!e.getMessage().contains("Socket closed")) {
log.warn("An IOException occurred while waiting to accept a TCP socket connection.", e);
}
}
}
});
log.info("TCP Socket has been shut down.");
}, "TcpSocketThread").start();
}
@EventListener(ContextClosedEvent.class)
public void closeServer() throws IOException {
serverSocket.close();
for (var linkManager : linkManagers) linkManager.shutdown();
}
private void initializeConnection(Socket socket) throws IOException {
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
DataInputStream in = new DataInputStream(socket.getInputStream());
int tokenLength = in.readInt();
short tokenLength = in.readShort();
String rawToken = new String(in.readNBytes(tokenLength));
if (rawToken.length() < ComponentAccessToken.PREFIX_SIZE) {
byte[] respBytes = JsonUtils.toJson(Map.of("message", "Invalid token")).getBytes(StandardCharsets.UTF_8);
out.writeInt(respBytes.length);
out.write(respBytes);
if (rawToken.length() < LinkToken.PREFIX_SIZE) {
JsonUtils.writeJsonString(out, new ConnectMessage(false, "Invalid or missing token."));
socket.close();
}
Iterable<ComponentAccessToken> tokens = tokenRepository.findAllByTokenPrefix(rawToken.substring(0, ComponentAccessToken.PREFIX_SIZE));
for (var token : tokens) {
if (passwordEncoder.matches(rawToken, token.getTokenHash())) {
} else {
Iterable<LinkToken> tokens = tokenRepository.findAllByTokenPrefix(rawToken.substring(0, LinkToken.PREFIX_SIZE));
for (var token : tokens) {
if (passwordEncoder.matches(rawToken, token.getTokenHash())) {
JsonUtils.writeJsonString(out, new ConnectMessage(true, "Connection established."));
var linkManager = new TcpLinkManager(token.getId(), socket, componentDownlinkService, uplinkMessageHandler);
new Thread(linkManager, "linkManager-" + token.getId()).start();
linkManagers.add(linkManager);
return;
}
}
JsonUtils.writeJsonString(out, new ConnectMessage(false, "Invalid token."));
socket.close();
}
}
}

View File

@ -0,0 +1,80 @@
package nl.andrewl.railsignalapi.live.websocket;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.model.component.Component;
import nl.andrewl.railsignalapi.rest.dto.component.out.ComponentResponse;
import nl.andrewl.railsignalapi.util.JsonUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* A service that can be used to send live updates of a rail system's state
* to connected front-end web apps.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AppUpdateService {
private final Map<Long, Set<WebSocketSession>> sessions = new HashMap<>();
private final ComponentRepository<Component> componentRepository;
public synchronized void registerSession(long rsId, WebSocketSession session) {
Set<WebSocketSession> sessionsForRs = sessions.computeIfAbsent(rsId, x -> new HashSet<>());
sessionsForRs.add(session);
}
public synchronized void deregisterSession(WebSocketSession session) {
Set<Long> orphans = new HashSet<>();
// Remove the session from any rail systems it's subscribed to.
for (var entry : sessions.entrySet()) {
if (entry.getValue().contains(session)) {
entry.getValue().remove(session);
if (entry.getValue().isEmpty()) {
orphans.add(entry.getKey());
}
}
}
// Clean up the sessions map by removing any rail systems for which there are no subscriptions.
for (var orphanRsId : orphans) {
sessions.remove(orphanRsId);
}
}
public synchronized void sendUpdate(long rsId, Object msg) {
Set<WebSocketSession> sessionsForRs = sessions.get(rsId);
if (sessionsForRs != null) {
try {
String json = JsonUtils.toJson(msg);
for (var session : sessionsForRs) {
try {
session.sendMessage(new TextMessage(json));
} catch (IOException e) {
log.warn("An error occurred when sending message to websocket session.", e);
}
}
} catch (IOException e) {
log.error("Failed to produce JSON for message update for apps.", e);
}
}
}
@Transactional(readOnly = true)
public void sendComponentUpdate(long rsId, long componentId) {
componentRepository.findByIdAndRailSystemId(componentId, rsId).ifPresent(component -> {
ComponentResponse msg = ComponentResponse.of(component);
sendUpdate(rsId, msg);
});
}
}

View File

@ -16,20 +16,21 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
@RequiredArgsConstructor
@Slf4j
public class AppWebsocketHandler extends TextWebSocketHandler {
private final AppUpdateService appUpdateService;
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
super.afterConnectionEstablished(session);
log.info("App websocket session established.");
public void afterConnectionEstablished(WebSocketSession session) {
long railSystemId = (long) session.getAttributes().get("railSystemId");
appUpdateService.registerSession(railSystemId, session);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
super.handleTextMessage(session, message);
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
// Don't do anything with messages from the web app. At least not yet.
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
super.afterConnectionClosed(session, status);
log.info("App websocket session closed.");
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
appUpdateService.deregisterSession(session);
}
}

View File

@ -23,7 +23,7 @@ public class AppWebsocketHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) {
String path = request.getURI().getPath();
Long railSystemId = Long.parseLong(path.substring(path.lastIndexOf('/')));
Long railSystemId = Long.parseLong(path.substring(path.lastIndexOf('/') + 1));
if (!railSystemRepository.existsById(railSystemId)) {
response.setStatusCode(HttpStatus.NOT_FOUND);
return false;

View File

@ -2,8 +2,10 @@ package nl.andrewl.railsignalapi.live.websocket;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.dao.ComponentAccessTokenRepository;
import nl.andrewl.railsignalapi.live.ComponentDownlinkService;
import nl.andrewl.railsignalapi.live.ComponentUplinkMessageHandler;
import nl.andrewl.railsignalapi.live.dto.ComponentUplinkMessage;
import nl.andrewl.railsignalapi.util.JsonUtils;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.socket.CloseStatus;
@ -11,9 +13,6 @@ import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Handler for websocket connections that components open to send and receive
* real-time updates from the server.
@ -22,24 +21,20 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
@Slf4j
public class ComponentWebsocketHandler extends TextWebSocketHandler {
private final ComponentAccessTokenRepository tokenRepository;
private final ComponentDownlinkService componentDownlinkService;
private final ComponentUplinkMessageHandler uplinkMessageHandler;
@Override
@Transactional(readOnly = true)
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
public void afterConnectionEstablished(WebSocketSession session) {
long tokenId = (long) session.getAttributes().get("tokenId");
var token = tokenRepository.findById(tokenId).orElseThrow();
Set<Long> componentIds = token.getComponents().stream()
.map(nl.andrewl.railsignalapi.model.component.Component::getId)
.collect(Collectors.toSet());
componentDownlinkService.registerDownlink(new WebsocketDownlink(tokenId, session), componentIds);
componentDownlinkService.registerDownlink(new WebsocketDownlink(tokenId, session));
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// var msg = mapper.readValue(message.getPayload(), SignalUpdateMessage.class);
//signalService.handleSignalUpdate(msg);
var msg = JsonUtils.readMessage(message.getPayload(), ComponentUplinkMessage.class);
uplinkMessageHandler.messageReceived(msg);
}
@Override

View File

@ -1,8 +1,8 @@
package nl.andrewl.railsignalapi.live.websocket;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.dao.ComponentAccessTokenRepository;
import nl.andrewl.railsignalapi.model.ComponentAccessToken;
import nl.andrewl.railsignalapi.dao.LinkTokenRepository;
import nl.andrewl.railsignalapi.model.LinkToken;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
@ -22,7 +22,7 @@ import java.util.Map;
@Component
@RequiredArgsConstructor
public class ComponentWebsocketHandshakeInterceptor implements HandshakeInterceptor {
private final ComponentAccessTokenRepository tokenRepository;
private final LinkTokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder;
@Override
@ -34,11 +34,11 @@ public class ComponentWebsocketHandshakeInterceptor implements HandshakeIntercep
return false;
}
String rawToken = query.substring(tokenIdx);
if (rawToken.length() < ComponentAccessToken.PREFIX_SIZE) {
if (rawToken.length() < LinkToken.PREFIX_SIZE) {
response.setStatusCode(HttpStatus.BAD_REQUEST);
return false;
}
Iterable<ComponentAccessToken> tokens = tokenRepository.findAllByTokenPrefix(rawToken.substring(0, ComponentAccessToken.PREFIX_SIZE));
Iterable<LinkToken> tokens = tokenRepository.findAllByTokenPrefix(rawToken.substring(0, LinkToken.PREFIX_SIZE));
for (var token : tokens) {
if (passwordEncoder.matches(rawToken, token.getTokenHash())) {
attributes.put("tokenId", token.getId());

View File

@ -17,7 +17,7 @@ import java.util.Set;
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ComponentAccessToken {
public class LinkToken {
public static final byte PREFIX_SIZE = 7;
@Id
@ -54,7 +54,7 @@ public class ComponentAccessToken {
@ManyToMany
private Set<Component> components;
public ComponentAccessToken(RailSystem railSystem, String label, String tokenPrefix, String tokenHash, Set<Component> components) {
public LinkToken(RailSystem railSystem, String label, String tokenPrefix, String tokenHash, Set<Component> components) {
this.railSystem = railSystem;
this.label = label;
this.tokenPrefix = tokenPrefix;

View File

@ -3,6 +3,7 @@ package nl.andrewl.railsignalapi.model;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import nl.andrewl.railsignalapi.model.component.SegmentBoundaryNode;
import nl.andrewl.railsignalapi.model.component.Signal;
@ -31,6 +32,13 @@ public class Segment {
@Column
private String name;
/**
* Whether this segment is occupied by a train.
*/
@Column(nullable = false)
@Setter
private boolean occupied;
/**
* The signals that are connected to this branch.
*/

View File

@ -1,8 +1,9 @@
package nl.andrewl.railsignalapi.model;
package nl.andrewl.railsignalapi.model.component;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import nl.andrewl.railsignalapi.model.RailSystem;
import nl.andrewl.railsignalapi.model.component.Component;
import nl.andrewl.railsignalapi.model.component.ComponentType;
import nl.andrewl.railsignalapi.model.component.Position;

View File

@ -20,7 +20,8 @@ import java.util.Set;
public class SegmentBoundaryNode extends PathNode {
/**
* The set of segments that this boundary node connects. This should
* generally always have exactly two segments.
* generally always have exactly two segments. It can never have more than
* two segments.
*/
@ManyToMany
private Set<Segment> segments;

View File

@ -7,7 +7,9 @@ import lombok.Setter;
import nl.andrewl.railsignalapi.model.RailSystem;
import javax.persistence.*;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* A switch is a component that directs traffic between several connected
@ -36,4 +38,12 @@ public class Switch extends PathNode {
this.possibleConfigurations = possibleConfigurations;
this.activeConfiguration = activeConfiguration;
}
public Optional<SwitchConfiguration> findConfiguration(Set<Long> pathNodeIds) {
for (var config : possibleConfigurations) {
Set<Long> configNodeIds = config.getNodes().stream().map(Component::getId).collect(Collectors.toSet());
if (pathNodeIds.equals(configNodeIds)) return Optional.of(config);
}
return Optional.empty();
}
}

View File

@ -0,0 +1,28 @@
package nl.andrewl.railsignalapi.rest;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/**").permitAll();
http.cors().disable();
http.csrf().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.formLogin().disable();
http.logout().disable();
http.httpBasic().disable();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}

View File

@ -22,6 +22,4 @@ public class WebConfig implements WebMvcConfigurer {
.allowedOrigins("*")
.allowedMethods("*");
}
}

View File

@ -5,13 +5,11 @@ import nl.andrewl.railsignalapi.model.Segment;
public class SegmentResponse {
public long id;
public String name;
public SegmentResponse(long id, String name) {
this.id = id;
this.name = name;
}
public boolean occupied;
public SegmentResponse(Segment s) {
this(s.getId(), s.getName());
this.id = s.getId();
this.name = s.getName();
this.occupied = s.isOccupied();
}
}

View File

@ -3,7 +3,7 @@ package nl.andrewl.railsignalapi.rest.dto.component.in;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import nl.andrewl.railsignalapi.model.Label;
import nl.andrewl.railsignalapi.model.component.Label;
import nl.andrewl.railsignalapi.model.component.Position;
import javax.validation.constraints.NotBlank;

View File

@ -1,6 +1,6 @@
package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.Label;
import nl.andrewl.railsignalapi.model.component.Label;
import nl.andrewl.railsignalapi.model.component.*;
/**

View File

@ -1,6 +1,6 @@
package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.Label;
import nl.andrewl.railsignalapi.model.component.Label;
public class LabelResponse extends ComponentResponse {
public String text;

View File

@ -4,7 +4,7 @@ import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.dao.RailSystemRepository;
import nl.andrewl.railsignalapi.dao.SegmentRepository;
import nl.andrewl.railsignalapi.model.Label;
import nl.andrewl.railsignalapi.model.component.Label;
import nl.andrewl.railsignalapi.model.RailSystem;
import nl.andrewl.railsignalapi.model.Segment;
import nl.andrewl.railsignalapi.model.component.*;
@ -66,6 +66,7 @@ public class ComponentCreationService {
private Component createSwitch(RailSystem rs, SwitchPayload payload) {
Switch s = new Switch(rs, payload.position, payload.name, new HashSet<>(), new HashSet<>(), null);
s = componentRepository.save(s);
for (var config : payload.possibleConfigurations) {
Set<PathNode> pathNodes = new HashSet<>();
for (var node : config.nodes) {
@ -74,6 +75,8 @@ public class ComponentCreationService {
if (c instanceof PathNode pathNode) {
pathNodes.add(pathNode);
s.getConnectedNodes().add(pathNode);
pathNode.getConnectedNodes().add(s);
componentRepository.save(pathNode);
} else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Id " + node.id + " does not refer to a PathNode component.");
}

View File

@ -4,16 +4,22 @@ import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.dao.RailSystemRepository;
import nl.andrewl.railsignalapi.dao.SegmentRepository;
import nl.andrewl.railsignalapi.live.ComponentDownlinkService;
import nl.andrewl.railsignalapi.live.dto.SegmentBoundaryUpdateMessage;
import nl.andrewl.railsignalapi.live.dto.SegmentStatusMessage;
import nl.andrewl.railsignalapi.live.websocket.AppUpdateService;
import nl.andrewl.railsignalapi.model.Segment;
import nl.andrewl.railsignalapi.model.component.Component;
import nl.andrewl.railsignalapi.rest.dto.SegmentPayload;
import nl.andrewl.railsignalapi.model.component.SegmentBoundaryNode;
import nl.andrewl.railsignalapi.rest.dto.FullSegmentResponse;
import nl.andrewl.railsignalapi.rest.dto.SegmentPayload;
import nl.andrewl.railsignalapi.rest.dto.SegmentResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.List;
@Service
@ -22,6 +28,10 @@ public class SegmentService {
private final SegmentRepository segmentRepository;
private final RailSystemRepository railSystemRepository;
private final ComponentRepository<Component> componentRepository;
private final ComponentRepository<SegmentBoundaryNode> segmentBoundaryRepository;
private final ComponentDownlinkService downlinkService;
private final AppUpdateService appUpdateService;
@Transactional(readOnly = true)
public List<SegmentResponse> getSegments(long rsId) {
@ -55,4 +65,41 @@ public class SegmentService {
componentRepository.deleteAll(segment.getBoundaryNodes());
segmentRepository.delete(segment);
}
private void sendSegmentOccupiedStatus(Segment segment) {
for (var signal : segment.getSignals()) {
downlinkService.sendMessage(signal.getId(), new SegmentStatusMessage(signal.getId(), segment.isOccupied()));
appUpdateService.sendComponentUpdate(segment.getRailSystem().getId(), signal.getId());
}
}
@Transactional
public void onBoundaryUpdate(SegmentBoundaryUpdateMessage msg) {
var segmentBoundary = segmentBoundaryRepository.findById(msg.cId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
switch (msg.eventType) {
case ENTERING -> {
for (var segment : segmentBoundary.getSegments()) {
segment.setOccupied(true);
}
segmentRepository.saveAll(segmentBoundary.getSegments());
}
case ENTERED -> {
List<Segment> otherSegments = new ArrayList<>(segmentBoundary.getSegments());
// Set the "to" segment as occupied.
segmentRepository.findById(msg.toSegmentId).ifPresent(segment -> {
segment.setOccupied(true);
segmentRepository.save(segment);
sendSegmentOccupiedStatus(segment);
otherSegments.remove(segment);
});
// And all others as no longer occupied.
for (var segment : otherSegments) {
segment.setOccupied(false);
segmentRepository.save(segment);
sendSegmentOccupiedStatus(segment);
}
}
}
}
}

View File

@ -0,0 +1,30 @@
package nl.andrewl.railsignalapi.service;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.live.ComponentDownlinkService;
import nl.andrewl.railsignalapi.live.dto.SwitchUpdateMessage;
import nl.andrewl.railsignalapi.live.websocket.AppUpdateService;
import nl.andrewl.railsignalapi.model.component.Switch;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class SwitchService {
private final ComponentRepository<Switch> switchRepository;
private final ComponentDownlinkService downlinkService;
private final AppUpdateService appUpdateService;
@Transactional
public void onSwitchUpdate(SwitchUpdateMessage msg) {
switchRepository.findById(msg.cId).ifPresent(sw -> {
sw.findConfiguration(msg.configuration).ifPresent(config -> {
sw.setActiveConfiguration(config);
switchRepository.save(sw);
appUpdateService.sendComponentUpdate(sw.getRailSystem().getId(), sw.getId());
});
});
}
}

View File

@ -1,12 +1,38 @@
package nl.andrewl.railsignalapi.util;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class JsonUtils {
private static final ObjectMapper mapper = new ObjectMapper();
static {
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
public static String toJson(Object o) throws JsonProcessingException {
return mapper.writeValueAsString(o);
}
public static void writeJsonString(DataOutputStream out, Object o) throws IOException {
byte[] data = toJson(o).getBytes(StandardCharsets.UTF_8);
if (data.length > Short.MAX_VALUE) throw new IOException("Data is too large!");
out.writeShort(data.length);
out.write(data);
}
public static <T> T readMessage(DataInputStream in, Class<T> type) throws IOException {
short len = in.readShort();
byte[] data = in.readNBytes(len);
return mapper.readValue(data, type);
}
public static <T> T readMessage(String in, Class<T> type) throws IOException {
return mapper.readValue(in, type);
}
}