Added first implementation of CC:Tweaked driver and improved UI.

This commit is contained in:
Andrew Lalis 2022-05-29 17:02:05 +02:00
parent 2771e934c9
commit 3b2797f259
26 changed files with 229 additions and 48 deletions

View File

@ -1,19 +1,18 @@
# RailSignalAPI
A simple API for tracking rail traffic in signalled blocks.
# Rail Signal
A comprehensive solution to tracking and managing your rail system, in real time.
You can download the program via [releases](https://github.com/andrewlalis/RailSignalAPI/releases).
## Development
To work on and develop Rail Signal, you will need to run both the Java/Spring-Boot backend API, and the Vue/Quasar frontend app.
Once you download the JAR file, you can simply run it with `java -jar <jarfile>`. Note that this program requires Java 17.
To start up the API, the project directory in IntelliJ (or the IDE of your choice), and run the `RailSignalApiApplication` main method.
Once it's started up, navigate to http://localhost:8080 to view the RailSignal web interface, where you can make changes to your systems. You should start by creating a new rail system.
To start up the app, open a terminal in the `quasar-app` directory, and run `quasar dev`.
Once you've done that, you can go ahead and create some signals for your system.
## Immersive Railroading and ComputerCraft
To begin controlling your signals from within the game, you can set up a signal controller computer with a two detector augments on the rail (one for redstone, one for the computer connection) and two monitors. Make sure all perhipherals are connected to the network, and then run this command:
### Building
To build a complete API/app distributable JAR file, simply run the following:
```
pastebin run Z72QhG7G
./build_system.d
```
This will run an installation script that will guide you through setting up your signal's configuration.
> Note: The build script requires the D language toolchain to be installed on your system. Also, you can compile `build_system.d` to a native executable to run the build script more efficiently.
See [here](https://imgur.com/a/685HV7d) for some examples of how to build the signal structures within your world.
This will produce a `rail-signal-api-XXX.jar` file in the `target` directory, which contains both the API, and the frontend app, packaged together so that the entire JAR can simply be run via `java -jar`.

View File

@ -80,7 +80,7 @@ Signals display the status of a connected segment, and as such can only receive
{
"cId": 123,
"type": "SEGMENT_STATUS",
"sId": 4,
"segmentId": 4,
"occupied": true
}
```

View File

@ -97,3 +97,16 @@ export function removeComponent(rs, id) {
.catch(reject);
});
}
export function updateSwitchConfiguration(rs, sw, configId) {
return new Promise((resolve, reject) => {
axios.post(
`${API_URL}/rs/${rs.id}/c/${sw.id}/activeConfiguration`,
{
activeConfigurationId: configId
}
)
.then(resolve)
.catch(reject);
});
}

View File

@ -51,7 +51,7 @@ export function createLinkToken(rs, data) {
* @param {RailSystem} rs
* @param {Number} tokenId
*/
export function deleteToken(rs, tokenId) {
export function deleteLinkToken(rs, tokenId) {
return new Promise((resolve, reject) => {
axios.delete(`${API_URL}/rs/${rs.id}/lt/${tokenId}`)
.then(() => {

View File

@ -23,7 +23,15 @@ export function establishWebsocketConnection(rs) {
}
};
rs.websocket.onmessage = msg => {
console.log(msg);
const data = JSON.parse(msg.data);
console.log(data);
if (data.type === "COMPONENT_DATA") {
const id = data.cId;
const idx = rs.components.findIndex(c => c.id === id);
if (idx > -1) {
rs.components[idx] = data.data;
}
}
};
rs.websocket.onerror = error => {
console.log(error);

View File

@ -5,7 +5,7 @@
<q-item-label caption>Id: {{segment.id}}</q-item-label>
</q-item-section>
<q-item-section side v-if="segment.occupied">
<q-badge align="middle"></q-badge>
<q-chip>Occupied</q-chip>
</q-item-section>
</q-item>
</template>

View File

@ -7,6 +7,12 @@
<q-item-label>{{component.type}}</q-item-label>
<q-item-label caption>Id: {{component.id}}</q-item-label>
</q-item-section>
<q-item-section v-if="component.online === true" top side>
<q-chip color="positive" text-color="white">Online</q-chip>
</q-item-section>
<q-item-section v-if="component.online === false" top side>
<q-chip color="negative" text-color="white">Offline</q-chip>
</q-item-section>
</q-item>
<q-item clickable>
@ -100,6 +106,9 @@
/>
</q-item-label>
</q-item-section>
<q-item-section v-if="component.activeConfiguration === null || component.activeConfiguration.id !== config.id" side>
<q-btn dense size="sm" color="positive" @click="setActiveSwitchConfig(component, config.id)">Set Active</q-btn>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
@ -118,7 +127,7 @@ import { RailSystem } from "src/api/railSystems";
import { useRailSystemsStore } from "stores/railSystemsStore";
import SegmentListItem from "components/rs/SegmentListItem.vue";
import { useQuasar } from "quasar";
import { removeComponent } from "src/api/components";
import { removeComponent, updateSwitchConfiguration } from "src/api/components";
export default {
name: "SelectedComponentView",
@ -166,6 +175,9 @@ export default {
});
});
});
},
setActiveSwitchConfig(sw, configId) {
updateSwitchConfiguration(this.railSystem, sw, configId);
}
}
};

View File

@ -11,11 +11,11 @@
v-for="token in railSystem.linkTokens"
:key="token.id"
>
<q-item-section>
<q-item-section top>
<q-item-label>{{token.label}}</q-item-label>
<q-item-label caption>{{token.id}}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-section top>
<q-item-label caption>Components</q-item-label>
<q-item-label>
<q-chip
@ -27,6 +27,9 @@
/>
</q-item-label>
</q-item-section>
<q-item-section top side>
<q-btn size="12px" flat dense round icon="delete" @click="deleteToken(token)"/>
</q-item-section>
</q-item>
<q-item v-if="railSystem.linkTokens.length === 0">
<q-item-section>
@ -102,7 +105,7 @@
<script>
import { RailSystem } from "src/api/railSystems";
import { createLinkToken } from "src/api/linkTokens";
import { createLinkToken, deleteLinkToken } from "src/api/linkTokens";
import { useQuasar } from "quasar";
export default {
@ -134,6 +137,27 @@ export default {
return component.type === "SIGNAL" || component.type === "SEGMENT_BOUNDARY" || component.type === "SWITCH";
});
},
deleteToken(token) {
this.quasar.dialog({
title: "Confirm Removal",
message: "Are you sure you want to remove this token? All devices using it will need to be given a new token.",
cancel: true
}).onOk(() => {
deleteLinkToken(this.railSystem, token.id)
.then(() => {
this.quasar.notify({
color: "positive",
message: "Token removed."
});
})
.catch(error => {
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
});
},
onSubmit() {
this.addTokenData.componentIds = this.addTokenData.selectedComponents.map(c => c.id);
createLinkToken(this.railSystem, this.addTokenData)

View File

@ -37,11 +37,21 @@ export function drawComponent(ctx, worldTx, component) {
}
}
function drawSignal(ctx) {
function drawSignal(ctx, signal) {
roundedRect(ctx, -0.3, -0.5, 0.6, 1, 0.25);
ctx.fillStyle = "black";
ctx.fill();
ctx.fillStyle = "rgb(0, 255, 0)";
if (signal.segment) {
if (signal.segment.occupied === true) {
ctx.fillStyle = `rgb(255, 0, 0)`;
} else if (signal.segment.occupied === false) {
ctx.fillStyle = `rgb(0, 255, 0)`;
} else {
ctx.fillStyle = `rgb(255, 255, 0)`;
}
} else {
ctx.fillStyle = `rgb(0, 0, 255)`;
}
circle(ctx, 0, -0.2, 0.15);
ctx.fill();
}

View File

@ -16,6 +16,11 @@ import java.util.stream.Collectors;
/**
* A service that manages all the active component downlink connections.
*
* We keep track of active component downlinks by storing a mapping which maps
* each downlink to the set of components it is responsible for, and another
* mapping that maps each online component to the set of downlinks that are
* responsible for it.
*/
@Service
@RequiredArgsConstructor
@ -29,7 +34,9 @@ public class ComponentDownlinkService {
private final AppUpdateService appUpdateService;
/**
* Registers a new active downlink to one or more components.
* Registers a new active downlink to one or more components. Sets all
* linked components as online, and sends messages to any connected apps
* to notify them of the update components.
* @param downlink The downlink to register.
*/
@Transactional
@ -38,7 +45,7 @@ public class ComponentDownlinkService {
componentDownlinks.put(downlink, components.stream().map(Component::getId).collect(Collectors.toSet()));
for (var c : components) {
c.setOnline(true);
componentRepository.save(c);
componentRepository.saveAndFlush(c); // Make sure to flush, so that online status is immediately visible everywhere.
// Immediately send a data message to the downlink and app for each component that comes online.
var msg = new ComponentDataMessage(c);

View File

@ -11,16 +11,16 @@ public class SegmentStatusMessage extends ComponentMessage {
/**
* The id of the segment that updated.
*/
private final long sId;
private final long segmentId;
/**
* Whether the segment is occupied.
*/
private final boolean occupied;
public SegmentStatusMessage(long cId, long sId, boolean occupied) {
public SegmentStatusMessage(long cId, long segmentId, boolean occupied) {
super(cId, "SEGMENT_STATUS");
this.sId = sId;
this.segmentId = segmentId;
this.occupied = occupied;
}
}

View File

@ -3,8 +3,8 @@ package nl.andrewl.railsignalapi.live.websocket;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.live.dto.ComponentDataMessage;
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;
@ -77,11 +77,9 @@ public class AppUpdateService {
}
}
@Transactional(readOnly = true)
public void sendComponentUpdate(long rsId, long componentId) {
componentRepository.findByIdAndRailSystemId(componentId, rsId).ifPresent(component -> {
ComponentResponse msg = ComponentResponse.of(component);
sendUpdate(rsId, msg);
sendUpdate(rsId, new ComponentDataMessage(component));
});
}
}

View File

@ -7,7 +7,6 @@ import nl.andrewl.railsignalapi.live.ComponentUplinkMessageHandler;
import nl.andrewl.railsignalapi.live.dto.ComponentMessage;
import nl.andrewl.railsignalapi.util.JsonUtils;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
@ -27,7 +26,6 @@ public class ComponentWebsocketHandler extends TextWebSocketHandler {
private final ComponentUplinkMessageHandler uplinkMessageHandler;
@Override
@Transactional(readOnly = true)
public void afterConnectionEstablished(WebSocketSession session) {
long tokenId = (long) session.getAttributes().get("tokenId");
try {

View File

@ -33,7 +33,7 @@ public class ComponentWebsocketHandshakeInterceptor implements HandshakeIntercep
response.setStatusCode(HttpStatus.BAD_REQUEST);
return false;
}
String rawToken = query.substring(tokenIdx);
String rawToken = query.substring(tokenIdx + "token=".length());
if (rawToken.length() < LinkToken.PREFIX_SIZE) {
response.setStatusCode(HttpStatus.BAD_REQUEST);
return false;

View File

@ -1,6 +1,7 @@
package nl.andrewl.railsignalapi.live.websocket;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
@ -17,6 +18,7 @@ import java.util.Set;
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
@Slf4j
public class WebsocketConfig implements WebSocketConfigurer {
private final ComponentWebsocketHandler componentHandler;
private final ComponentWebsocketHandshakeInterceptor componentInterceptor;
@ -34,6 +36,7 @@ public class WebsocketConfig implements WebSocketConfigurer {
// If we're in a development profile, allow any origin to access the app websocket.
// This is so that we can use a standalone JS dev server.
if (Set.of(env.getActiveProfiles()).contains("development")) {
log.info("Allowing all origins to access app websocket because development profile is active.");
appHandlerReg.setAllowedOrigins("*");
}
}

View File

@ -2,6 +2,7 @@ package nl.andrewl.railsignalapi.rest;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.rest.dto.component.in.ComponentPayload;
import nl.andrewl.railsignalapi.rest.dto.component.in.SwitchConfigurationUpdatePayload;
import nl.andrewl.railsignalapi.rest.dto.component.out.ComponentResponse;
import nl.andrewl.railsignalapi.rest.dto.component.out.SimpleComponentResponse;
import nl.andrewl.railsignalapi.service.ComponentCreationService;
@ -57,4 +58,10 @@ public class ComponentsApiController {
public ComponentResponse updateComponent(@PathVariable long rsId, @PathVariable long cId, @RequestBody @Valid ComponentPayload payload) {
return componentService.updateComponent(rsId, cId, payload);
}
@PostMapping(path = "/{cId}/activeConfiguration")
public ResponseEntity<Void> updateSwitchConfiguration(@PathVariable long rsId, @PathVariable long cId, @RequestBody @Valid SwitchConfigurationUpdatePayload payload) {
componentService.updateSwitchConfiguration(rsId, cId, payload);
return ResponseEntity.noContent().build();
}
}

View File

@ -1,9 +1,9 @@
package nl.andrewl.railsignalapi.rest;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.rest.dto.LinkTokenCreatedResponse;
import nl.andrewl.railsignalapi.rest.dto.LinkTokenPayload;
import nl.andrewl.railsignalapi.rest.dto.LinkTokenResponse;
import nl.andrewl.railsignalapi.rest.dto.link_token.LinkTokenCreatedResponse;
import nl.andrewl.railsignalapi.rest.dto.link_token.LinkTokenPayload;
import nl.andrewl.railsignalapi.rest.dto.link_token.LinkTokenResponse;
import nl.andrewl.railsignalapi.service.LinkTokenService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

View File

@ -0,0 +1,21 @@
package nl.andrewl.railsignalapi.rest;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.rest.dto.link_token.StandaloneLinkTokenResponse;
import nl.andrewl.railsignalapi.service.LinkTokenService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(path = "/api/lt/{token}")
@RequiredArgsConstructor
public class StandaloneLinkTokenApiController {
private final LinkTokenService tokenService;
@GetMapping
public StandaloneLinkTokenResponse getToken(@PathVariable String token) {
return tokenService.getToken(token);
}
}

View File

@ -0,0 +1,8 @@
package nl.andrewl.railsignalapi.rest.dto.component.in;
import javax.validation.constraints.NotNull;
public record SwitchConfigurationUpdatePayload(
@NotNull
long activeConfigurationId
) {}

View File

@ -1,4 +1,4 @@
package nl.andrewl.railsignalapi.rest.dto;
package nl.andrewl.railsignalapi.rest.dto.link_token;
public record LinkTokenCreatedResponse(
String token

View File

@ -1,4 +1,4 @@
package nl.andrewl.railsignalapi.rest.dto;
package nl.andrewl.railsignalapi.rest.dto.link_token;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;

View File

@ -1,4 +1,4 @@
package nl.andrewl.railsignalapi.rest.dto;
package nl.andrewl.railsignalapi.rest.dto.link_token;
import nl.andrewl.railsignalapi.model.LinkToken;
import nl.andrewl.railsignalapi.model.component.Component;

View File

@ -0,0 +1,37 @@
package nl.andrewl.railsignalapi.rest.dto.link_token;
import nl.andrewl.railsignalapi.model.LinkToken;
import nl.andrewl.railsignalapi.model.component.Component;
import nl.andrewl.railsignalapi.rest.dto.component.out.SimpleComponentResponse;
import java.util.Comparator;
import java.util.List;
/**
* A standalone response for link tokens that includes extra information about
* the rail system that the token is for.
* @param id The token id.
* @param label The token's label.
* @param components The list of components that the token manages.
* @param rsId The rail system id.
* @param rsName The rail system's name.
*/
public record StandaloneLinkTokenResponse (
long id,
String label,
List<SimpleComponentResponse> components,
long rsId,
String rsName
) {
public StandaloneLinkTokenResponse(LinkToken token) {
this(
token.getId(),
token.getLabel(),
token.getComponents().stream()
.sorted(Comparator.comparing(Component::getName))
.map(SimpleComponentResponse::new).toList(),
token.getRailSystem().getId(),
token.getRailSystem().getName()
);
}
}

View File

@ -5,6 +5,10 @@ import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.dao.RailSystemRepository;
import nl.andrewl.railsignalapi.dao.SegmentRepository;
import nl.andrewl.railsignalapi.dao.SwitchConfigurationRepository;
import nl.andrewl.railsignalapi.live.ComponentDownlinkService;
import nl.andrewl.railsignalapi.live.dto.ComponentDataMessage;
import nl.andrewl.railsignalapi.live.dto.SwitchUpdateMessage;
import nl.andrewl.railsignalapi.live.websocket.AppUpdateService;
import nl.andrewl.railsignalapi.model.Segment;
import nl.andrewl.railsignalapi.model.component.*;
import nl.andrewl.railsignalapi.rest.dto.component.in.*;
@ -30,6 +34,8 @@ public class ComponentService {
private final RailSystemRepository railSystemRepository;
private final SwitchConfigurationRepository switchConfigurationRepository;
private final SegmentRepository segmentRepository;
private final AppUpdateService appUpdateService;
private final ComponentDownlinkService downlinkService;
@Transactional(readOnly = true)
public List<ComponentResponse> getComponents(long rsId) {
@ -99,7 +105,9 @@ public class ComponentService {
if (c instanceof Switch sw && payload instanceof SwitchPayload sp) {
updateSwitch(sw, sp, rsId);
}
return ComponentResponse.of(componentRepository.save(c));
c = componentRepository.save(c);
appUpdateService.sendUpdate(rsId, new ComponentDataMessage(c));
return ComponentResponse.of(c);
}
private void updateSignal(Signal s, SignalPayload sp, long rsId) {
@ -183,4 +191,21 @@ public class ComponentService {
componentRepository.save(node);
}
}
@Transactional
public void updateSwitchConfiguration(long rsId, long cId, SwitchConfigurationUpdatePayload payload) {
var component = componentRepository.findByIdAndRailSystemId(cId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (component instanceof Switch sw) {
for (var config : sw.getPossibleConfigurations()) {
if (config.getId().equals(payload.activeConfigurationId())) {
downlinkService.sendMessage(new SwitchUpdateMessage(sw.getId(), config.getId()));
return;
}
}
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid activeConfigurationId");
} else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Component is not a switch.");
}
}
}

View File

@ -8,9 +8,10 @@ import nl.andrewl.railsignalapi.live.ComponentDownlinkService;
import nl.andrewl.railsignalapi.model.LinkToken;
import nl.andrewl.railsignalapi.model.component.Component;
import nl.andrewl.railsignalapi.model.component.ComponentType;
import nl.andrewl.railsignalapi.rest.dto.LinkTokenCreatedResponse;
import nl.andrewl.railsignalapi.rest.dto.LinkTokenPayload;
import nl.andrewl.railsignalapi.rest.dto.LinkTokenResponse;
import nl.andrewl.railsignalapi.rest.dto.link_token.LinkTokenCreatedResponse;
import nl.andrewl.railsignalapi.rest.dto.link_token.LinkTokenPayload;
import nl.andrewl.railsignalapi.rest.dto.link_token.LinkTokenResponse;
import nl.andrewl.railsignalapi.rest.dto.link_token.StandaloneLinkTokenResponse;
import nl.andrewl.railsignalapi.util.StringUtils;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
@ -82,4 +83,9 @@ public class LinkTokenService {
componentDownlinkService.deregisterDownlink(token.getId());
tokenRepository.delete(token);
}
@Transactional(readOnly = true)
public StandaloneLinkTokenResponse getToken(String rawToken) {
return new StandaloneLinkTokenResponse(validateToken(rawToken).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)));
}
}

View File

@ -84,9 +84,12 @@ public class SegmentService {
switch (msg.eventType) {
case ENTERING -> {
for (var segment : segmentBoundary.getSegments()) {
segment.setOccupied(true);
if (!segment.isOccupied()) {
segment.setOccupied(true);
segmentRepository.save(segment);
}
sendSegmentOccupiedStatus(segment);
}
segmentRepository.saveAll(segmentBoundary.getSegments());
}
case ENTERED -> {
List<Segment> otherSegments = new ArrayList<>(segmentBoundary.getSegments());
@ -99,8 +102,10 @@ public class SegmentService {
});
// And all others as no longer occupied.
for (var segment : otherSegments) {
segment.setOccupied(false);
segmentRepository.save(segment);
if (segment.isOccupied()) {
segment.setOccupied(false);
segmentRepository.save(segment);
}
sendSegmentOccupiedStatus(segment);
}
}