Add SIN.04028 tutorial material, tutor prompt and conversation log

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 15:11:02 +02:00
co-authored by Claude Opus 5
parent bac80295b7
commit f615bef426
30 changed files with 8123 additions and 0 deletions
@@ -0,0 +1,25 @@
String rot13(String text) {
var result = new StringBuilder();
for (var character : text.toCharArray()) {
if (character >= 'a' && character <= 'z')
character = (char) ('a' + (character - 'a' + 13) % 26);
else if (character >= 'A' && character <= 'Z')
character = (char) ('A' + (character - 'A' + 13) % 26);
result.append(character);
}
return result.toString();
}
void main() {
while (true) {
var text = IO.readln();
if (text == null)
return;
IO.println(rot13(text));
}
}
@@ -0,0 +1,88 @@
record Message(String type, String value) {}
Map<String, BlockingQueue<Message>> mailboxes =
new ConcurrentHashMap<>();
BlockingQueue<Message> mailbox(String name) {
return mailboxes.computeIfAbsent(
name,
_ -> new LinkedBlockingQueue<>()
);
}
String rot13(String text) {
var result = new StringBuilder();
for (var character : text.toCharArray()) {
if (character >= 'a' && character <= 'z')
character = (char) ('a' + (character - 'a' + 13) % 26);
else if (character >= 'A' && character <= 'Z')
character = (char) ('A' + (character - 'A' + 13) % 26);
result.append(character);
}
return result.toString();
}
void mediate(String from, String to) {
try {
while (true)
mailbox(to).put(mailbox(from).take());
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
}
}
void runConsoleIn() {
try {
while (true) {
var text = IO.readln();
if (text == null)
return;
mailbox("consoleIn.outbox")
.put(new Message("TEXT", text));
}
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
}
}
void runRot13() {
try {
while (true) {
var input = mailbox("rot13.inbox").take();
var output = new Message("TEXT", rot13(input.value()));
mailbox("rot13.outbox").put(output);
}
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
}
}
void runConsoleOut() {
try {
while (true)
IO.println(mailbox("consoleOut.inbox").take().value());
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
}
}
void main() throws InterruptedException {
Thread.ofVirtual().start(this::runConsoleIn);
Thread.ofVirtual().start(this::runRot13);
Thread.ofVirtual().start(this::runConsoleOut);
Thread.ofVirtual().start(
() -> mediate("consoleIn.outbox", "rot13.inbox")
);
Thread.ofVirtual().start(
() -> mediate("rot13.outbox", "consoleOut.inbox")
);
Thread.currentThread().join();
}
@@ -0,0 +1,6 @@
void main() throws Exception {
FileMessageBoard.watch("consoleOut/inbox", message -> {
if (message.type().equals("TEXT"))
IO.println(message.value());
});
}
@@ -0,0 +1,69 @@
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.concurrent.LinkedBlockingQueue;
record Route(String from, String to) {}
Route waitForRoute(String controlMailbox) throws InterruptedException {
var messages = new LinkedBlockingQueue<Message>();
var controlListener = Thread.ofVirtual().start(() -> {
try {
FileMessageBoard.watch(controlMailbox, messages::add);
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
});
try {
while (true) {
var message = messages.take();
if (!message.type().equals("ROUTE")) {
IO.println("Ignored " + message.type() + "; expected ROUTE");
continue;
}
var mailboxes = message.value().lines().toList();
if (mailboxes.size() != 2
|| mailboxes.get(0).isBlank()
|| mailboxes.get(1).isBlank()) {
IO.println("ROUTE needs exactly two lines: <from> and <to>");
continue;
}
return new Route(mailboxes.get(0), mailboxes.get(1));
}
} finally {
controlListener.interrupt();
controlListener.join();
}
}
void main(String[] arguments) throws Exception {
if (arguments.length != 1
|| !arguments[0].matches("[A-Za-z0-9_-]+")) {
IO.println("Usage: java ConfigurableMediator.java <mediator-name>");
return;
}
var name = arguments[0];
var controlMailbox = "mediators/" + name + "/inbox";
IO.println(name + " waits for ROUTE on " + controlMailbox);
var route = waitForRoute(controlMailbox);
IO.println(name + " mediates: " + route.from() + " -> " + route.to());
FileMessageBoard.watch(route.from(), message -> {
try {
FileMessageBoard.publish(route.to(), message);
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
});
}
@@ -0,0 +1,107 @@
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardWatchEventKinds;
import java.time.Instant;
import java.util.Arrays;
import java.util.UUID;
import java.util.function.Consumer;
final class FileMessageBoard {
static final Path ROOT = Path.of(
System.getProperty("messageBoard.root", "messageBoard")
).toAbsolutePath().normalize();
private FileMessageBoard() {}
static String serialize(Message message) {
var text = message.type() + "\n" + message.value();
if (message.timestamp() != null)
text += "\nTIMESTAMP: " + message.timestamp();
return text;
}
static Message read(Path file) throws IOException {
var lines = Files.readString(file).split("\\R", -1);
if (lines.length < 2)
throw new IOException("A message needs a type and a value: " + file);
var endOfValue = lines.length;
Instant timestamp = null;
var lastLine = lines[lines.length - 1];
if (lastLine.startsWith("TIMESTAMP: ")) {
timestamp = Instant.parse(lastLine.substring("TIMESTAMP: ".length()));
endOfValue--;
}
var value = String.join(
"\n",
Arrays.copyOfRange(lines, 1, endOfValue)
);
return new Message(lines[0], value, timestamp);
}
static void publish(String mailbox, Message message) throws IOException {
publishText(mailbox, serialize(message));
}
static void publishText(String mailbox, String content) throws IOException {
var directory = directory(mailbox);
Files.createDirectories(directory);
var id = UUID.randomUUID().toString();
var temporary = directory.resolve(id + ".tmp");
var published = directory.resolve(id + ".msg");
Files.writeString(temporary, content);
Files.move(temporary, published, StandardCopyOption.ATOMIC_MOVE);
}
static void watch(String mailbox, Consumer<Message> receiver)
throws IOException, InterruptedException {
var directory = directory(mailbox);
Files.createDirectories(directory);
try (var watcher = FileSystems.getDefault().newWatchService()) {
directory.register(watcher, StandardWatchEventKinds.ENTRY_CREATE);
while (true) {
var key = watcher.take();
for (var event : key.pollEvents()) {
if (event.kind() == StandardWatchEventKinds.OVERFLOW)
continue;
var filename = (Path) event.context();
if (!filename.toString().endsWith(".msg"))
continue;
var file = directory.resolve(filename);
if (Files.isRegularFile(file))
receiver.accept(read(file));
}
if (!key.reset())
return;
}
}
}
static Path directory(String name) {
var directory = ROOT.resolve(name).normalize();
if (!directory.startsWith(ROOT))
throw new IllegalArgumentException("Mailbox escapes the message board");
return directory;
}
}
@@ -0,0 +1,19 @@
void main(String[] arguments) throws Exception {
if (arguments.length != 2) {
IO.println("Usage: java Mediator.java <from-mailbox> <to-mailbox>");
return;
}
var from = arguments[0];
var to = arguments[1];
IO.println("Mediating: " + from + " -> " + to);
FileMessageBoard.watch(from, message -> {
try {
FileMessageBoard.publish(to, message);
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
});
}
@@ -0,0 +1,22 @@
void configure(String mediator, String from, String to) throws Exception {
FileMessageBoard.publish(
"mediators/" + mediator + "/inbox",
new Message("ROUTE", from + "\n" + to)
);
}
void main() throws Exception {
configure(
"mediator1",
"consoleIn/outbox",
"rot13/inbox"
);
configure(
"mediator2",
"rot13/outbox",
"consoleOut/inbox"
);
IO.println("Configured mediator1 and mediator2");
}
@@ -0,0 +1,7 @@
import java.time.Instant;
record Message(String type, String value, Instant timestamp) {
Message(String type, String value) {
this(type, value, Instant.now());
}
}
@@ -0,0 +1,30 @@
String rot13(String text) {
var result = new StringBuilder();
for (var character : text.toCharArray()) {
if (character >= 'a' && character <= 'z')
character = (char) ('a' + (character - 'a' + 13) % 26);
else if (character >= 'A' && character <= 'Z')
character = (char) ('A' + (character - 'A' + 13) % 26);
result.append(character);
}
return result.toString();
}
void main() throws Exception {
FileMessageBoard.watch("rot13/inbox", message -> {
if (!message.type().equals("TEXT"))
return;
try {
FileMessageBoard.publish(
"rot13/outbox",
new Message("TEXT", rot13(message.value()))
);
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
});
}
@@ -0,0 +1,13 @@
void main() throws Exception {
while (true) {
var text = IO.readln();
if (text == null)
return;
FileMessageBoard.publish(
"consoleIn/outbox",
new Message("TEXT", text)
);
}
}
@@ -0,0 +1,136 @@
record Message(String type, String value) {}
Map<String, BlockingQueue<Message>> mailboxes =
new ConcurrentHashMap<>();
BlockingQueue<Message> mailbox(String name) {
return mailboxes.computeIfAbsent(
name,
_ -> new LinkedBlockingQueue<>()
);
}
void listen(String mailboxName, Consumer<Message> receiver) {
try {
while (true)
receiver.accept(mailbox(mailboxName).take());
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
}
}
String rot13(String text) {
var result = new StringBuilder();
for (var character : text.toCharArray()) {
if (character >= 'a' && character <= 'z')
character = (char) ('a' + (character - 'a' + 13) % 26);
else if (character >= 'A' && character <= 'Z')
character = (char) ('A' + (character - 'A' + 13) % 26);
result.append(character);
}
return result.toString();
}
void runConsoleIn() {
try {
while (true) {
var text = IO.readln();
if (text == null)
return;
mailbox("consoleIn.outbox")
.put(new Message("TEXT", text));
}
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
}
}
Map<String, Thread> rot13Subscriptions = new HashMap<>();
Map<String, Thread> consoleOutSubscriptions = new HashMap<>();
void subscribe(
Map<String, Thread> subscriptions,
String mailboxName,
Consumer<Message> receiver) {
subscriptions.computeIfAbsent(
mailboxName,
name -> Thread.ofVirtual().start(() -> listen(name, receiver))
);
}
void processWithRot13(Message message) {
if (!message.type().equals("TEXT"))
return;
try {
mailbox("rot13.outbox").put(
new Message("TEXT", rot13(message.value()))
);
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
}
}
void runRot13() {
try {
while (true) {
var message = mailbox("rot13.inbox").take();
if (message.type().equals("TOPIC"))
subscribe(
rot13Subscriptions,
message.value(),
this::processWithRot13
);
else
processWithRot13(message);
}
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
}
}
void print(Message message) {
if (message.type().equals("TEXT"))
IO.println(message.value());
}
void runConsoleOut() {
try {
while (true) {
var message = mailbox("consoleOut.inbox").take();
if (message.type().equals("TOPIC"))
subscribe(
consoleOutSubscriptions,
message.value(),
this::print
);
else
print(message);
}
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
}
}
void main() throws Exception {
Thread.ofVirtual().start(this::runConsoleIn);
Thread.ofVirtual().start(this::runRot13);
Thread.ofVirtual().start(this::runConsoleOut);
mailbox("rot13.inbox").put(
new Message("TOPIC", "consoleIn.outbox")
);
mailbox("consoleOut.inbox").put(
new Message("TOPIC", "rot13.outbox")
);
Thread.currentThread().join();
}
@@ -0,0 +1,13 @@
void main() throws Exception {
FileMessageBoard.publish(
"rot13/inbox",
new Message("TOPIC", "consoleIn/outbox")
);
FileMessageBoard.publish(
"consoleOut/inbox",
new Message("TOPIC", "rot13/outbox")
);
IO.println("Wired: consoleIn -> rot13 -> consoleOut");
}
@@ -0,0 +1,24 @@
void main() throws Exception {
try (var subscriptions = new FileSubscriptions()) {
var controlMailbox = "consoleOut/inbox";
String textMailbox = null;
subscriptions.subscribe(controlMailbox);
while (true) {
var incoming = subscriptions.take();
var message = incoming.message();
if (incoming.mailbox().equals(controlMailbox)
&& message.type().equals("TOPIC")) {
if (textMailbox != null)
subscriptions.unsubscribe(textMailbox);
textMailbox = message.value();
subscriptions.subscribe(textMailbox);
IO.println("ConsoleOut now listens to " + textMailbox);
} else if (message.type().equals("TEXT")) {
IO.println(message.value());
}
}
}
}
@@ -0,0 +1,23 @@
void main(String[] arguments) throws Exception {
if (arguments.length != 3
|| !arguments[0].matches("[A-Za-z0-9_-]+")) {
IO.println(
"Usage: java DynamicMediatorOrchestrator.java "
+ "<mediator-name> <from-mailbox> <to-mailbox>"
);
return;
}
var mediator = arguments[0];
var from = arguments[1];
var to = arguments[2];
FileMessageBoard.publish(
"mediators/" + mediator + "/inbox",
new Message("ROUTE", from + "\n" + to)
);
IO.println(
"Requested: " + mediator + " " + from + " -> " + to
);
}
@@ -0,0 +1,88 @@
record DynamicRoute(String from, String to) {}
DynamicRoute readRoute(Message message, String controlMailbox) {
var mailboxes = message.value().lines().toList();
if (mailboxes.size() != 2
|| mailboxes.get(0).isBlank()
|| mailboxes.get(1).isBlank()) {
IO.println("ROUTE needs exactly two lines: <from> and <to>");
return null;
}
var route = new DynamicRoute(mailboxes.get(0), mailboxes.get(1));
try {
FileMessageBoard.directory(route.from());
FileMessageBoard.directory(route.to());
} catch (IllegalArgumentException exception) {
IO.println("Rejected ROUTE: " + exception.getMessage());
return null;
}
if (route.from().equals(route.to())) {
IO.println("Rejected ROUTE: from and to must differ");
return null;
}
if (route.from().equals(controlMailbox)
|| route.to().equals(controlMailbox)) {
IO.println("Rejected ROUTE: the control mailbox is not a data mailbox");
return null;
}
return route;
}
void main(String[] arguments) throws Exception {
if (arguments.length != 1
|| !arguments[0].matches("[A-Za-z0-9_-]+")) {
IO.println("Usage: java DynamicMediatorService.java <mediator-name>");
return;
}
var name = arguments[0];
var controlMailbox = "mediators/" + name + "/inbox";
try (var subscriptions = new FileSubscriptions()) {
DynamicRoute route = null;
subscriptions.subscribe(controlMailbox);
IO.println(name + " waits for ROUTE on " + controlMailbox);
while (true) {
var incoming = subscriptions.take();
var message = incoming.message();
if (incoming.mailbox().equals(controlMailbox)) {
if (!message.type().equals("ROUTE")) {
IO.println("Ignored " + message.type() + "; expected ROUTE");
continue;
}
var requestedRoute = readRoute(message, controlMailbox);
if (requestedRoute == null)
continue;
if (route == null
|| !route.from().equals(requestedRoute.from())) {
subscriptions.subscribe(requestedRoute.from());
if (route != null)
subscriptions.unsubscribe(route.from());
}
route = requestedRoute;
IO.println(
name + " now mediates: "
+ route.from() + " -> " + route.to()
);
} else if (route != null
&& incoming.mailbox().equals(route.from())) {
FileMessageBoard.publish(route.to(), message);
}
}
}
}
@@ -0,0 +1,42 @@
String rot13(String text) {
var result = new StringBuilder();
for (var character : text.toCharArray()) {
if (character >= 'a' && character <= 'z')
character = (char) ('a' + (character - 'a' + 13) % 26);
else if (character >= 'A' && character <= 'Z')
character = (char) ('A' + (character - 'A' + 13) % 26);
result.append(character);
}
return result.toString();
}
void main() throws Exception {
try (var subscriptions = new FileSubscriptions()) {
var controlMailbox = "rot13/inbox";
String textMailbox = null;
subscriptions.subscribe(controlMailbox);
while (true) {
var incoming = subscriptions.take();
var message = incoming.message();
if (incoming.mailbox().equals(controlMailbox)
&& message.type().equals("TOPIC")) {
if (textMailbox != null)
subscriptions.unsubscribe(textMailbox);
textMailbox = message.value();
subscriptions.subscribe(textMailbox);
IO.println("ROT13 now listens to " + textMailbox);
} else if (message.type().equals("TEXT")) {
FileMessageBoard.publish(
"rot13/outbox",
new Message("TEXT", rot13(message.value()))
);
}
}
}
}
@@ -0,0 +1,68 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.Arrays;
import java.util.UUID;
final class FileMessageBoard {
static final Path ROOT = Path.of(
System.getProperty("messageBoard.root", "messageBoard")
).toAbsolutePath().normalize();
private FileMessageBoard() {}
static String serialize(Message message) {
var text = message.type() + "\n" + message.value();
if (message.timestamp() != null)
text += "\nTIMESTAMP: " + message.timestamp();
return text;
}
static Message read(Path file) throws IOException {
var lines = Files.readString(file).split("\\R", -1);
if (lines.length < 2)
throw new IOException("A message needs a type and a value: " + file);
var endOfValue = lines.length;
Instant timestamp = null;
var lastLine = lines[lines.length - 1];
if (lastLine.startsWith("TIMESTAMP: ")) {
timestamp = Instant.parse(lastLine.substring("TIMESTAMP: ".length()));
endOfValue--;
}
var value = String.join(
"\n",
Arrays.copyOfRange(lines, 1, endOfValue)
);
return new Message(lines[0], value, timestamp);
}
static void publish(String mailbox, Message message) throws IOException {
var directory = directory(mailbox);
Files.createDirectories(directory);
var id = UUID.randomUUID().toString();
var temporary = directory.resolve(id + ".tmp");
var published = directory.resolve(id + ".msg");
Files.writeString(temporary, serialize(message));
Files.move(temporary, published, StandardCopyOption.ATOMIC_MOVE);
}
static Path directory(String name) {
var directory = ROOT.resolve(name).normalize();
if (!directory.startsWith(ROOT))
throw new IllegalArgumentException("Mailbox escapes the message board");
return directory;
}
}
@@ -0,0 +1,106 @@
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
record IncomingMessage(String mailbox, Message message) {}
final class FileSubscriptions implements AutoCloseable {
private final WatchService watcher;
private final Map<String, WatchKey> subscriptions = new ConcurrentHashMap<>();
private final Map<WatchKey, String> mailboxes = new ConcurrentHashMap<>();
private final BlockingQueue<IncomingMessage> messages =
new LinkedBlockingQueue<>();
private final Thread observer;
FileSubscriptions() throws IOException {
watcher = FileSystems.getDefault().newWatchService();
observer = Thread.ofVirtual().start(this::observe);
}
void subscribe(String mailbox) throws IOException {
if (subscriptions.containsKey(mailbox))
return;
var directory = FileMessageBoard.directory(mailbox);
Files.createDirectories(directory);
var key = directory.register(
watcher,
StandardWatchEventKinds.ENTRY_CREATE
);
subscriptions.put(mailbox, key);
mailboxes.put(key, mailbox);
}
void unsubscribe(String mailbox) {
var key = subscriptions.remove(mailbox);
if (key != null) {
mailboxes.remove(key);
key.cancel();
}
}
IncomingMessage take() throws InterruptedException {
return messages.take();
}
private void observe() {
try {
while (true) {
var key = watcher.take();
var mailbox = mailboxes.get(key);
if (mailbox == null) {
key.reset();
continue;
}
var directory = FileMessageBoard.directory(mailbox);
for (var event : key.pollEvents()) {
if (event.kind() == StandardWatchEventKinds.OVERFLOW)
continue;
var filename = (Path) event.context();
if (!filename.toString().endsWith(".msg"))
continue;
var file = directory.resolve(filename);
if (Files.isRegularFile(file))
messages.put(
new IncomingMessage(mailbox, FileMessageBoard.read(file))
);
}
if (!key.reset()) {
subscriptions.remove(mailbox);
mailboxes.remove(key);
}
}
} catch (ClosedWatchServiceException _) {
// close() ends the observer.
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
}
public void close() throws IOException {
watcher.close();
observer.interrupt();
}
}
@@ -0,0 +1,7 @@
import java.time.Instant;
record Message(String type, String value, Instant timestamp) {
Message(String type, String value) {
this(type, value, Instant.now());
}
}
@@ -0,0 +1,13 @@
void main() throws Exception {
while (true) {
var text = IO.readln();
if (text == null)
return;
FileMessageBoard.publish(
"consoleIn/outbox",
new Message("TEXT", text)
);
}
}
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>at.ac.fhstp.sin</groupId>
<artifactId>calling-to-eventing</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>26</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.mqttv5.client</artifactId>
<version>1.2.5</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.22.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>2.22.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.8.1</version>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,19 @@
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
final class JsonTools {
static final ObjectMapper deserializationObjectMapper =
new ObjectMapper(new YAMLFactory())
.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false
);
static final ObjectMapper serializationObjectMapper =
new ObjectMapper()
.configure(SerializationFeature.INDENT_OUTPUT, true);
private JsonTools() {}
}
@@ -0,0 +1,67 @@
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.eclipse.paho.mqttv5.client.IMqttAsyncClient;
import org.eclipse.paho.mqttv5.client.MqttAsyncClient;
import org.eclipse.paho.mqttv5.client.persist.MemoryPersistence;
import org.eclipse.paho.mqttv5.common.MqttMessage;
void publish(
IMqttAsyncClient client,
String topic,
String content,
int qos,
boolean retained)
throws Exception {
var message = new MqttMessage(
content.getBytes(StandardCharsets.UTF_8)
);
message.setQos(qos);
message.setRetained(retained);
client.publish(topic, message).waitForCompletion();
}
void main(String[] arguments) throws Exception {
var consoleInUnit = arguments.length > 0 ? arguments[0] : "alice";
var rot13Unit = arguments.length > 1 ? arguments[1] : "alice";
var consoleOutUnit = arguments.length > 2 ? arguments[2] : "alice";
var broker = System.getProperty("mqtt.broker", "tcp://localhost:1883");
var client = new MqttAsyncClient(
broker,
"configure-" + UUID.randomUUID(),
new MemoryPersistence()
);
client.connect().waitForCompletion();
var rot13Intent = """
subscribe:
topic: Tutorial/ConsoleIn/U/%s/E/text
""".formatted(consoleInUnit);
var consoleOutIntent = """
subscribe:
topic: Tutorial/ROT13/U/%s/E/crypted
""".formatted(rot13Unit);
publish(
client,
"Tutorial/ROT13/U/" + rot13Unit + "/I",
rot13Intent,
1,
false
);
publish(
client,
"Tutorial/ConsoleOut/U/" + consoleOutUnit + "/I",
consoleOutIntent,
1,
false
);
client.disconnect().waitForCompletion();
client.close();
IO.println("Wired: ConsoleIn -> ROT13 -> ConsoleOut");
}
@@ -0,0 +1,53 @@
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.eclipse.paho.mqttv5.client.IMqttAsyncClient;
import org.eclipse.paho.mqttv5.client.MqttAsyncClient;
import org.eclipse.paho.mqttv5.client.persist.MemoryPersistence;
import org.eclipse.paho.mqttv5.common.MqttMessage;
record ConsoleInMessage(String value) {}
void publish(
IMqttAsyncClient client,
String topic,
String content,
int qos,
boolean retained)
throws Exception {
var message = new MqttMessage(
content.getBytes(StandardCharsets.UTF_8)
);
message.setQos(qos);
message.setRetained(retained);
client.publish(topic, message).waitForCompletion();
}
void main(String[] arguments) throws Exception {
var unit = arguments.length == 0 ? "alice" : arguments[0];
var broker = System.getProperty("mqtt.broker", "tcp://localhost:1883");
var topic = "Tutorial/ConsoleIn/U/" + unit + "/E/text";
var client = new MqttAsyncClient(
broker,
"console-in-" + unit + "-" + UUID.randomUUID(),
new MemoryPersistence()
);
client.connect().waitForCompletion();
try {
while (true) {
var text = IO.readln();
if (text == null)
return;
var json = JsonTools.serializationObjectMapper
.writeValueAsString(new ConsoleInMessage(text));
publish(client, topic, json, 1, false);
}
} finally {
client.disconnect().waitForCompletion();
client.close();
}
}
@@ -0,0 +1,138 @@
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.eclipse.paho.mqttv5.client.IMqttAsyncClient;
import org.eclipse.paho.mqttv5.client.IMqttToken;
import org.eclipse.paho.mqttv5.client.MqttAsyncClient;
import org.eclipse.paho.mqttv5.client.MqttCallback;
import org.eclipse.paho.mqttv5.client.MqttConnectionOptions;
import org.eclipse.paho.mqttv5.client.MqttDisconnectResponse;
import org.eclipse.paho.mqttv5.client.persist.MemoryPersistence;
import org.eclipse.paho.mqttv5.common.MqttException;
import org.eclipse.paho.mqttv5.common.MqttMessage;
import org.eclipse.paho.mqttv5.common.packet.MqttProperties;
record ConsoleOutMessage(String value) {}
record ConsoleOutSubscription(String topic) {}
record ConsoleOutIntent(ConsoleOutSubscription subscribe) {}
MqttAsyncClient client;
String intentTopic;
String subscriptionStatusTopic;
String dynamicTextTopic;
void publish(
IMqttAsyncClient client,
String topic,
String content,
int qos,
boolean retained)
throws Exception {
var message = new MqttMessage(
content.getBytes(StandardCharsets.UTF_8)
);
message.setQos(qos);
message.setRetained(retained);
client.publish(topic, message).waitForCompletion();
}
MqttConnectionOptions connectionOptions(String lastWillTopic) {
var options = new MqttConnectionOptions();
options.setAutomaticReconnect(true);
var will = new MqttMessage(
"{\"value\":false}".getBytes(StandardCharsets.UTF_8),
1,
true,
new MqttProperties()
);
options.setWill(lastWillTopic, will);
return options;
}
void handleIntent(MqttMessage message) throws Exception {
var intent = JsonTools.deserializationObjectMapper.readValue(
message.getPayload(),
ConsoleOutIntent.class
);
if (intent.subscribe() == null)
return;
var requestedTopic = intent.subscribe().topic();
if (!MqttTools.isValidFilter(requestedTopic)) {
IO.println("Rejected invalid topic filter: " + requestedTopic);
return;
}
if (dynamicTextTopic != null)
client.unsubscribe(dynamicTextTopic).waitForCompletion();
dynamicTextTopic = requestedTopic;
client.subscribe(dynamicTextTopic, 1).waitForCompletion();
var status = JsonTools.serializationObjectMapper
.writeValueAsString(new ConsoleOutMessage(dynamicTextTopic));
publish(client, subscriptionStatusTopic, status, 1, true);
IO.println("ConsoleOut now listens to " + dynamicTextTopic);
}
void handlePublication(String topic, MqttMessage message) throws Exception {
if (topic.equals(intentTopic)) {
handleIntent(message);
} else if (dynamicTextTopic != null
&& MqttTools.isTopicMatchingFilter(topic, dynamicTextTopic)) {
var text = JsonTools.deserializationObjectMapper.readValue(
message.getPayload(),
ConsoleOutMessage.class
);
IO.println(text.value());
}
}
void main(String[] arguments) throws Exception {
var unit = arguments.length == 0 ? "alice" : arguments[0];
var broker = System.getProperty("mqtt.broker", "tcp://localhost:1883");
var root = "Tutorial/ConsoleOut/U/" + unit;
intentTopic = root + "/I";
subscriptionStatusTopic = root + "/S/subscriptions/text";
var onlineTopic = root + "/S/online";
client = new MqttAsyncClient(
broker,
"console-out-" + unit + "-" + UUID.randomUUID(),
new MemoryPersistence()
);
client.setCallback(new MqttCallback() {
public void messageArrived(String topic, MqttMessage message)
throws Exception {
handlePublication(topic, message);
}
public void disconnected(MqttDisconnectResponse response) {}
public void mqttErrorOccurred(MqttException exception) {
exception.printStackTrace();
}
public void deliveryComplete(IMqttToken token) {}
public void connectComplete(boolean reconnect, String serverURI) {}
public void authPacketArrived(
int reasonCode,
MqttProperties properties) {}
});
client.connect(connectionOptions(onlineTopic)).waitForCompletion();
client.subscribe(intentTopic, 1).waitForCompletion();
publish(client, onlineTopic, "{\"value\":true}", 1, true);
IO.println("ConsoleOut waits for Intent on " + intentTopic);
Thread.currentThread().join();
}
@@ -0,0 +1,172 @@
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.eclipse.paho.mqttv5.client.IMqttAsyncClient;
import org.eclipse.paho.mqttv5.client.IMqttToken;
import org.eclipse.paho.mqttv5.client.MqttAsyncClient;
import org.eclipse.paho.mqttv5.client.MqttCallback;
import org.eclipse.paho.mqttv5.client.MqttConnectionOptions;
import org.eclipse.paho.mqttv5.client.MqttDisconnectResponse;
import org.eclipse.paho.mqttv5.client.persist.MemoryPersistence;
import org.eclipse.paho.mqttv5.common.MqttException;
import org.eclipse.paho.mqttv5.common.MqttMessage;
import org.eclipse.paho.mqttv5.common.packet.MqttProperties;
record Rot13Message(String value) {}
record Rot13Subscription(String topic) {}
record Rot13Intent(
Rot13Subscription subscribe,
Rot13Message text
) {}
MqttAsyncClient client;
String intentTopic;
String eventTopic;
String subscriptionStatusTopic;
String dynamicTextTopic;
void publish(
IMqttAsyncClient client,
String topic,
String content,
int qos,
boolean retained)
throws Exception {
var message = new MqttMessage(
content.getBytes(StandardCharsets.UTF_8)
);
message.setQos(qos);
message.setRetained(retained);
client.publish(topic, message).waitForCompletion();
}
MqttConnectionOptions connectionOptions(String lastWillTopic) {
var options = new MqttConnectionOptions();
options.setAutomaticReconnect(true);
var will = new MqttMessage(
"{\"value\":false}".getBytes(StandardCharsets.UTF_8),
1,
true,
new MqttProperties()
);
options.setWill(lastWillTopic, will);
return options;
}
String rot13(String text) {
var result = new StringBuilder();
for (var character : text.toCharArray()) {
if (character >= 'a' && character <= 'z')
character = (char) ('a' + (character - 'a' + 13) % 26);
else if (character >= 'A' && character <= 'Z')
character = (char) ('A' + (character - 'A' + 13) % 26);
result.append(character);
}
return result.toString();
}
void cryptAndPublish(String text) throws Exception {
var json = JsonTools.serializationObjectMapper
.writeValueAsString(new Rot13Message(rot13(text)));
publish(client, eventTopic, json, 1, false);
}
void handleIntent(MqttMessage message) throws Exception {
var intent = JsonTools.deserializationObjectMapper.readValue(
message.getPayload(),
Rot13Intent.class
);
if (intent.subscribe() != null) {
var requestedTopic = intent.subscribe().topic();
if (!MqttTools.isValidFilter(requestedTopic)) {
IO.println("Rejected invalid topic filter: " + requestedTopic);
return;
}
if (dynamicTextTopic != null)
client.unsubscribe(dynamicTextTopic).waitForCompletion();
dynamicTextTopic = requestedTopic;
client.subscribe(dynamicTextTopic, 1).waitForCompletion();
var status = JsonTools.serializationObjectMapper
.writeValueAsString(new Rot13Message(dynamicTextTopic));
publish(
client,
subscriptionStatusTopic,
status,
1,
true
);
IO.println("ROT13 now listens to " + dynamicTextTopic);
}
if (intent.text() != null)
cryptAndPublish(intent.text().value());
}
void handlePublication(String topic, MqttMessage message) throws Exception {
if (topic.equals(intentTopic)) {
handleIntent(message);
} else if (dynamicTextTopic != null
&& MqttTools.isTopicMatchingFilter(topic, dynamicTextTopic)) {
var input = JsonTools.deserializationObjectMapper.readValue(
message.getPayload(),
Rot13Message.class
);
cryptAndPublish(input.value());
}
}
void main(String[] arguments) throws Exception {
var unit = arguments.length == 0 ? "alice" : arguments[0];
var broker = System.getProperty("mqtt.broker", "tcp://localhost:1883");
var root = "Tutorial/ROT13/U/" + unit;
intentTopic = root + "/I";
eventTopic = root + "/E/crypted";
subscriptionStatusTopic = root + "/S/subscriptions/text";
var onlineTopic = root + "/S/online";
client = new MqttAsyncClient(
broker,
"rot13-" + unit + "-" + UUID.randomUUID(),
new MemoryPersistence()
);
client.setCallback(new MqttCallback() {
public void messageArrived(String topic, MqttMessage message)
throws Exception {
handlePublication(topic, message);
}
public void disconnected(MqttDisconnectResponse response) {}
public void mqttErrorOccurred(MqttException exception) {
exception.printStackTrace();
}
public void deliveryComplete(IMqttToken token) {}
public void connectComplete(boolean reconnect, String serverURI) {}
public void authPacketArrived(
int reasonCode,
MqttProperties properties) {}
});
client.connect(connectionOptions(onlineTopic)).waitForCompletion();
client.subscribe(intentTopic, 1).waitForCompletion();
publish(client, onlineTopic, "{\"value\":true}", 1, true);
IO.println("ROT13 waits for Intent on " + intentTopic);
Thread.currentThread().join();
}
@@ -0,0 +1,66 @@
import java.nio.charset.StandardCharsets;
final class MqttTools {
private MqttTools() {}
static boolean isValidTopic(String topic) {
return hasValidLength(topic)
&& !topic.contains("+")
&& !topic.contains("#");
}
static boolean isValidFilter(String filter) {
if (!hasValidLength(filter))
return false;
var levels = filter.split("/", -1);
for (var index = 0; index < levels.length; index++) {
var level = levels[index];
if (level.contains("#")
&& !(level.equals("#") && index == levels.length - 1))
return false;
if (level.contains("+") && !level.equals("+"))
return false;
}
return true;
}
static boolean isTopicMatchingFilter(String topic, String filter) {
if (!isValidTopic(topic) || !isValidFilter(filter))
return false;
if (topic.startsWith("$") && !filter.startsWith("$"))
return false;
var topicLevels = topic.split("/", -1);
var filterLevels = filter.split("/", -1);
var topicIndex = 0;
for (var filterLevel : filterLevels) {
if (filterLevel.equals("#"))
return true;
if (topicIndex == topicLevels.length)
return false;
if (!filterLevel.equals("+")
&& !filterLevel.equals(topicLevels[topicIndex]))
return false;
topicIndex++;
}
return topicIndex == topicLevels.length;
}
private static boolean hasValidLength(String value) {
if (value == null || value.isEmpty() || value.contains("\u0000"))
return false;
return value.getBytes(StandardCharsets.UTF_8).length <= 65_535;
}
}