Processor/src/main/java/org/ulyssis/ipp/snapshot/MessageEvent.java
@JsonTypeName("Message")
public final class MessageEvent extends Event {
    private final String message;

    @JsonCreator
    public MessageEvent(
            @JsonProperty("time") Instant time,
            @JsonProperty("message") String message) {
        super(time);
        this.message = message;
    }

    @Override
    protected Snapshot doApply(Snapshot before) {
        return Snapshot.builder(getTime(), before).withStatusMessage(message).build();
    }

    public static MessageEvent fromCommand(Command command) {
        assert(command instanceof SetStatusMessageCommand);
        SetStatusMessageCommand cmd = (SetStatusMessageCommand) command;
        return new MessageEvent(cmd.getTime(), cmd.getMessage());
    }

    public String getMessage() {
        return message;
    }

    @Override
    public boolean isRemovable() {
        return true;
    }
}
Processor/src/main/java/org/ulyssis/ipp/snapshot/StartEvent.java
@JsonTypeName("Start")
public final class StartEvent extends Event {
    /**
     * Private constructor for Jackson
     */
    private StartEvent() {
        super(Instant.MIN);
    }

    public StartEvent(Instant time) {
        super(time);
    }

    @Override
    protected Snapshot doApply(Snapshot snapshot) {
        return Snapshot.builder(getTime(), snapshot).withStartTime(getTime()).build();
    }

    public static StartEvent fromCommand(Command command) {
        assert(command instanceof SetStartTimeCommand);
        SetStartTimeCommand cmd = (SetStartTimeCommand) command;
        return new StartEvent(cmd.getTime());
    }

    @Override
    public boolean isUnique() {
        return true;
    }
}
Processor/src/main/java/org/ulyssis/ipp/snapshot/Snapshot.java
public final class Snapshot {
    private static final Logger LOG = LogManager.getLogger(Snapshot.class);

    public static class Builder {
        private Snapshot snapshot;

        private Builder(Instant time, Snapshot other) {
            snapshot = new Snapshot(time);
            if (other != null) {
                snapshot.teamTagMap = other.teamTagMap;
                snapshot.startTime = other.startTime;
                snapshot.endTime = other.endTime;
                snapshot.teamStates = other.teamStates;
                snapshot.publicTeamStates = other.publicTeamStates;
                snapshot.statusMessage = other.statusMessage;
                snapshot.status = other.status;
                snapshot.updateFrequency = other.updateFrequency;
            }
        }

        public Builder withTeamTagMap(TeamTagMap teamTagMap) {
            this.snapshot.teamTagMap = teamTagMap;
            return this;
        }

        public Builder withStartTime(Instant time) {
            this.snapshot.startTime = time;
            return this;
        }

        public Builder withEndTime(Instant time) {
            this.snapshot.endTime = time;
            return this;
        }

        public Builder withTeamStates(TeamStates teamStates) {
            this.snapshot.teamStates = teamStates;
            return this;
        }

        public Builder withPublicTeamStates(TeamStates teamStates) {
            this.snapshot.publicTeamStates = teamStates;
            return this;
        }

        public Builder withStatusMessage(String message) {
            this.snapshot.statusMessage = message;
            return this;
        }

        public Builder withStatus(Status status) {
            this.snapshot.status = status;
            return this;
        }

        public Builder withUpdateFrequency(int updateFrequency) {
            this.snapshot.updateFrequency = updateFrequency;
            return this;
        }

        public Snapshot build() {
            return snapshot;
        }
    }

    @JsonIgnore
    private long id = -1;

    @JsonIgnore
    long eventId = -1;

    public Optional<Long> getId() {
        if (id != -1) return Optional.of(id);
        else return Optional.empty();
    }

    public Optional<Long> getEventId() {
        if (eventId != -1) return Optional.of(eventId);
        else return Optional.empty();
    }

    /**
     * Default constructor for Jackson
     */
    @SuppressWarnings("unused")
    private Snapshot() {
    }

    public Snapshot(Instant time) {
        snapshotTime = time;
    }

    public Instant getSnapshotTime() {
        return snapshotTime;
    }

    public TeamTagMap getTeamTagMap() {
        return teamTagMap;
    }

    public Instant getStartTime() {
        return startTime;
    }

    public Instant getEndTime() {
        return endTime;
    }

    public TeamStates getTeamStates() {
        return teamStates;
    }

    public TeamStates getPublicTeamStates() {
        return publicTeamStates;
    }

    public String getStatusMessage() {
        return statusMessage;
    }

    public Status getStatus() {
        return status;
    }

    public int getUpdateFrequency() {
        return updateFrequency;
    }

    private Instant snapshotTime = Instant.MIN;
    private Instant startTime = Instant.MAX;
    private Instant endTime = Instant.MAX;
    private TeamTagMap teamTagMap = new TeamTagMap();
    private TeamStates teamStates;
    private TeamStates publicTeamStates;
    {
        teamStates = new TeamStates();
        publicTeamStates = teamStates;
    }
    private String statusMessage = "";
    private Status status = Status.NoResults;
    private int updateFrequency = 3;

    public static Builder builder(Instant time, Snapshot other) {
        return new Builder(time, other);
    }

    public static Optional<Snapshot> loadForEvent(Connection connection, Event event) throws SQLException, IOException {
        String statement = "SELECT \"id\", \"data\" FROM \"snapshots\" WHERE \"event\" = ?";
        try (PreparedStatement stmt = connection.prepareStatement(statement)) {
            stmt.setLong(1, event.getId().get());
            LOG.debug("executing query: {}", stmt);
            ResultSet rs = stmt.executeQuery();
            if (rs.next()) {
                String data = rs.getString("data");
                Snapshot result = Serialization.getJsonMapper().readValue(data, Snapshot.class);
                result.id = rs.getLong("id");
                result.eventId = event.getId().get();
                return Optional.of(result);
            } else {
                return Optional.empty();
            }
        }
    }

    public static Optional<Snapshot> loadLatest(Connection connection) throws SQLException, IOException {
        String statement = "SELECT \"id\", \"data\", \"event\" FROM \"snapshots\" ORDER BY \"time\" DESC FETCH FIRST ROW ONLY";
        try (Statement stmt = connection.createStatement();
             ResultSet rs = stmt.executeQuery(statement)) {
            if (rs.next()) {
                String data = rs.getString("data");
                Snapshot result = Serialization.getJsonMapper().readValue(data, Snapshot.class);
                result.id = rs.getLong("id");
                result.eventId = rs.getLong("event");
                return Optional.of(result);
            } else {
                return Optional.empty();
            }
        }
    }

    public static Optional<Snapshot> loadBefore(Connection connection, Instant time) throws SQLException, IOException {
        String statement = "SELECT \"id\", \"data\", \"event\" FROM \"snapshots\" " +
                "WHERE \"time\" < ? ORDER BY \"time\" DESC, \"event\" DESC FETCH FIRST ROW ONLY";
        try (PreparedStatement stmt = connection.prepareStatement(statement)) {
            stmt.setTimestamp(1, Timestamp.from(time));
            LOG.debug("Executing query: {}", stmt);
            ResultSet rs = stmt.executeQuery();
            if (rs.next()) {
                String data = rs.getString("data");
                Snapshot result = Serialization.getJsonMapper().readValue(data, Snapshot.class);
                result.id = rs.getLong("id");
                result.eventId = rs.getLong("event");
                return Optional.of(result);
            } else {
                return Optional.empty();
            }
        }
    }

    public void save(Connection connection) throws SQLException {
        if (id != -1) return;
        try (PreparedStatement statement = connection.prepareStatement(
                "INSERT INTO \"snapshots\" (\"time\",\"data\",\"event\") VALUES (?,?,?)", Statement.RETURN_GENERATED_KEYS)) {
            statement.setTimestamp(1, Timestamp.from(snapshotTime));
            String serialized;
            try {
                serialized = Serialization.getJsonMapper().writeValueAsString(this);
            } catch (JsonProcessingException e) {
                assert false; // TODO(Roel): Programming error
                return;
            }
            statement.setString(2, serialized);
            statement.setLong(3, eventId);
            statement.executeUpdate();
            ResultSet keys = statement.getGeneratedKeys();
            keys.next();
            this.id = keys.getLong(1);
        }
    }

    public static void deleteAfter(Connection connection, Snapshot snapshot) throws SQLException {
        String statement =
                "DELETE FROM \"snapshots\" WHERE \"time\" > ? OR (\"time\" = ? AND \"event\" > ?)";
        try (PreparedStatement stmt = connection.prepareStatement(statement)) {
            Timestamp timestamp = Timestamp.from(snapshot.getSnapshotTime());
            stmt.setTimestamp(1, timestamp);
            stmt.setTimestamp(2, timestamp);
            stmt.setLong(3, snapshot.getEventId().orElse(-1L));
            LOG.debug("Executing query: {}", stmt);
            int affectedRows = stmt.executeUpdate();
            LOG.debug("deleteAfter affected {} rows", affectedRows);
        }
    }
}
shared/src/main/java/org/ulyssis/ipp/control/commands/SetStatusCommand.java
@JsonTypeName("SetStatus")
public final class SetStatusCommand extends Command {
    private final Status status;

    public SetStatusCommand(Status status) {
        super();
        this.status = status;
    }

    @JsonCreator
    private SetStatusCommand(@JsonProperty("time") Instant time,
                             @JsonProperty("status") Status status) {
        super(time);
        this.status = status;
    }

    public Status getStatus() {
        return status;
    }
}
shared/src/main/java/org/ulyssis/ipp/TagId.java
@JsonSerialize(using=TagId.Serializer.class)
@JsonDeserialize(using=TagId.Deserializer.class)
public final class TagId {
    private final String id;
    private final String lowerCaseId;
    private final int hashCode;

    static class Serializer extends JsonSerializer<TagId> {
        @Override
        public void serialize(TagId value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
            jgen.writeString(value.toString());
        }
    }

    static class Deserializer extends JsonDeserializer<TagId> {
        @Override
        public TagId deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException {
            return new TagId(jp.getValueAsString());
        }
    }

    /**
     * = Create a new TagId from the given byte array.
     *
     * @param id
     *        The tag id in byte array form. It will be
     *        converted to a lowercase hex form.
     */
    public TagId(byte[] id) {
        this(BaseEncoding.base16().lowerCase().encode(id));
    }

    /**
     * = Create a new TagId from the given byte list.
     *
     * @param id
     *        The tag id in byte list form. It will be
     *        converted to a lowercase hex form.
     */
    public TagId(List<Byte> id) {
        this(BaseEncoding.base16().lowerCase().encode(Bytes.toArray(id)));
    }

    /**
     * = Create a new TagId
     *
     * @param id
     *        A string that uniquely identifies the tag
     * @throws java.lang.NullPointerException
     *         A NullPointerException will be thrown if the given id is null.
     */
    public TagId(String id) throws NullPointerException {
        if (id == null) throw new NullPointerException("The tag id is not allowed to be null!");
        this.id = id;
        this.lowerCaseId = id.toLowerCase();
        this.hashCode = this.lowerCaseId.hashCode();
    }

    /**
     * = Get the tag id as a string
     *
     * @return This tag id as a string
     */
    @Override
    public String toString() {
        return id;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) return true;
        if (other == null) return false;
        if (!(other instanceof TagId)) return false;
        return this.lowerCaseId.equals(((TagId) other).lowerCaseId);
    }

    @Override
    public int hashCode() {
        return this.hashCode;
    }
}
shared/src/main/java/org/ulyssis/ipp/utils/JedisHelper.java
public final class JedisHelper {

    // Static methods only! No instances!
    private JedisHelper() {
    }

    /**
     * Get a new Jedis instance from the given URI
     *
     * The URI is formed as such:
     * `new URI("redis://:password@host:port/database")`
     * for example:
     * `new URI("redis://:hunter2@10.0.0.1:6379/0")}`.
     * The port, password and path can be omitted. If no path is
     * supplied, database +0+ is selected. If no user
     * info is supplied, then no authorization is performed.
     * If no port is supplied, the default port +6379+
     * is used.
     *
     * Use this instead of `new Jedis(uri)`, because
     * that method does not allow to omit anything.
     *
     * @param uri
     *        The URI for the Jedis instance.
     * @return A new Jedis instance.
     */
    // TODO: Exceptions for wrong password, failing to parse database,...
    public static Jedis get(URI uri) throws IllegalArgumentException {
        int port = uri.getPort() != -1 ? uri.getPort() : 6379;
        String host = uri.getHost();
        Jedis result = new Jedis(host, port);
        if (uri.getUserInfo() != null) {
            String password = uri.getUserInfo().split(":")[1];
            result.auth(password);
        }
        result.select(getDb(uri));
        return result;
    }

    /**
     * Extract the database number from the given Redis URI
     *
     * If the URI has the path +/N+, then +N+
     * is returned. If the path is empty, +0+ is returned.
     *
     * @param uri
     *        The URI
     * @throws java.lang.IllegalArgumentException
     *         The supplied path could not be parsed as an int
     * @return The database number (+0+ for no path, +N+
     *         if the path is +/N+)
     */
    public static int getDb(URI uri) throws IllegalArgumentException {
        if (!Objects.equals(uri.getPath(), "")) {
            try {
                return Integer.parseInt(uri.getPath().split("/", 2)[1]);
            } catch (NumberFormatException e) {
                throw new IllegalArgumentException("The supplied path could not be parsed as an int", e);
            }
        }
        return 0;
    }

    /**
     * Generates a channel name that is local to a database.
     *
     * Redis channels are not tied to a single database, but are
     * global to the instance of Redis. We, however, want to tie
     * these to a certain database, so we have to add some information
     * that does that. For this reason, if the channel name is
     * +channel+, the database number +N+ is added to it
     * to form +channel:N+. The database number is extracted
     * using `getDb(uri)`.
     *
     * @param channel
     *        The base channel name
     * @param uri
     *        The URI containing the database info
     * @return +channel:N+, where +N+ is the database number
     * @see org.ulyssis.ipp.utils.JedisHelper#getDb(java.net.URI)
     */
    public static String dbLocalChannel(String channel, URI uri) {
        return channel + ":" + getDb(uri);
    }

    /**
     * A helper class that implements +BinaryJedisPubSub+ using callbacks
     * that can be registered.
     *
     * Instead of having to implement six methods where you probably
     * only need one, you only need to supply callbacks for the ones you're
     * interested in. This becomes extra useful when using Java 8, because
     * function references or lambda functions can be used.
     *
     * Adding a callback is thread safe. +CopyOnWriteArrayList+ is used,
     * because this class is optimized for frequent calling of callbacks,
     * not for registering callback functions.
     *
     * All callbacks are called from the thread currently blocking on
     * a +subscribe()+ (or similar) call.
     *
     * Currently, callbacks can only be added, not removed.
     */
    public static class BinaryCallBackPubSub extends BinaryJedisPubSub {
        /**
         * This is necessary, because there is no triconsumer.
         */
        @FunctionalInterface
        public interface OnPMessageCallback {
            public void accept(byte[] pattern, byte[] channel, byte[] message);
        }

        private final List<BiConsumer<byte[],byte[]>> onMessageCallbacks
                = new CopyOnWriteArrayList<>();
        private final List<OnPMessageCallback> onPMessageCallbacks
                = new CopyOnWriteArrayList<>();
        private final List<BiConsumer<byte[],Integer>> onSubscribeCallbacks
                = new CopyOnWriteArrayList<>();
        private final List<BiConsumer<byte[],Integer>> onUnsubscribeCallbacks
                = new CopyOnWriteArrayList<>();
        private final List<BiConsumer<byte[],Integer>> onPSubscribeCallbacks
                = new CopyOnWriteArrayList<>();
        private final List<BiConsumer<byte[],Integer>> onPUnsubscribeCallbacks
                = new CopyOnWriteArrayList<>();

        /**
         * Implementation of +onMessage+. Forwards the call to the registered callbacks.
         *
         * @see org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub#addOnMessageListener(java.util.function.BiConsumer)
         */
        @Override
        public void onMessage(byte[] channel, byte[] message) {
            onMessageCallbacks.stream().forEach(callback -> callback.accept(channel, message));
        }

        /**
         * Add a callback for +onMessage+.
         *
         * @param callback
         *        The callback to add. The first argument is the
         *        channel, the second is the message.
         * @see org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub#onMessage(byte[], byte[])
         */
        public void addOnMessageListener(BiConsumer<byte[],byte[]> callback) {
            onMessageCallbacks.add(callback);
        }

        /**
         * Implementation of +onPMessage+. Forwards the call to the registered callbacks.
         *
         * @see org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub#addOnPMessageListener(org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub.OnPMessageCallback)
         */
        @Override
        public void onPMessage(byte[] pattern, byte[] channel, byte[] message) {
            onPMessageCallbacks.stream().forEach(callback -> callback.accept(pattern, channel, message));
        }

        /**
         * Add a callback for +onPMessage+.
         *
         * @param callback
         *        The callback to add. The first argument is the
         *        pattern is the channel, the second is the channel,
         *        and the third is the message.
         * @see org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub#onPMessage(byte[], byte[], byte[])
         */
        public void addOnPMessageListener(OnPMessageCallback callback) {
            onPMessageCallbacks.add(callback);
        }

        /**
         * Implementation of +onSubscribe+. Forwards the call to the registered callbacks.
         *
         * @see org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub#addOnSubscribeListener(java.util.function.BiConsumer)
         */
        @Override
        public void onSubscribe(byte[] channel, int subscribedChannels) {
            onSubscribeCallbacks.stream().forEach(callback -> callback.accept(channel, subscribedChannels));
        }

        /**
         * Add a callback for +onSubscribe+
         *
         * @param callback
         *        The callback to add. The first argument is the channel,
         *        the second is the number of subscribed channels.
         * @see org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub#onSubscribe(byte[], int)
         */
        public void addOnSubscribeListener(BiConsumer<byte[], Integer> callback) {
            onSubscribeCallbacks.add(callback);
        }

        /**
         * Implementation of +onUnsubscribe+. Forwards the call to the registered callbacks.
         *
         * @see org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub#addOnUnsubscribeListener(java.util.function.BiConsumer)
         */
        @Override
        public void onUnsubscribe(byte[] channel, int subscribedChannels) {
            onUnsubscribeCallbacks.stream().forEach(callback -> callback.accept(channel, subscribedChannels));
        }

        /**
         * Add a callback for +onUnsubscribe+
         *
         * @param callback
         *        The callback to add. The first argument is the channel,
         *        the second is the number of subscribed channels.
         * @see org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub#onUnsubscribe(byte[], int)
         */
        public void addOnUnsubscribeListener(BiConsumer<byte[], Integer> callback) {
            onUnsubscribeCallbacks.add(callback);
        }

        /**
         * Implementation of +onPSubscribe+. Forwards the call to the registered callbacks.
         *
         * @see org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub#addOnPSubscribeListener(java.util.function.BiConsumer)
         */
        @Override
        public void onPSubscribe(byte[] pattern, int subscribedChannels) {
            onPSubscribeCallbacks.stream().forEach(callback -> callback.accept(pattern, subscribedChannels));
        }

        /**
         * Add a callback for +onPSubscribe+
         *
         * @param callback
         *        The callback to add. The first argument is the pattern,
         *        the second is the number of subscribed channels.
         * @see org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub#onPSubscribe(byte[], int)
         */
        public void addOnPSubscribeListener(BiConsumer<byte[], Integer> callback) {
            onPSubscribeCallbacks.add(callback);
        }

        /**
         * Implementation of +onPUnsubscribe+. Forwards the call to the registered callbacks.
         *
         * @see org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub#addOnPUnsubscribeListener(java.util.function.BiConsumer)
         */
        @Override
        public void onPUnsubscribe(byte[] pattern, int subscribedChannels) {
            onPUnsubscribeCallbacks.stream().forEach(callback -> callback.accept(pattern, subscribedChannels));
        }

        /**
         * Add a callback for +onPUnsubscribe+
         *
         * @param callback
         *        The callback to add. The first argument is the pattern,
         *        the second is the number of subscribed channels.
         * @see org.ulyssis.ipp.utils.JedisHelper.BinaryCallBackPubSub#onPUnsubscribe(byte[], int)
         */
        public void addOnPUnsubscribeListener(BiConsumer<byte[], Integer> callback) {
            onPUnsubscribeCallbacks.add(callback);
        }
    }

    /**
     * A helper class that implements +JedisPubSub+ using callbacks
     * that can be registered.
     *
     * Instead of having to implement six methods where you probably
     * only need one, you only need to supply callbacks for the ones you're
     * interested in. This becomes extra useful when using Java 8, because
     * function references or lambda functions can be used.
     *
     * Adding a callback is thread safe. +CopyOnWriteArrayList+ is used,
     * because this class is optimized for frequent calling of callbacks,
     * not for registering callback functions.
     *
     * All callbacks are called from the thread currently blocking on
     * a +subscribe()+ (or similar) call.
     *
     * Currently, callbacks can only be added, not removed.
     */
    public static class CallBackPubSub extends JedisPubSub {
        /**
         * This is necessary, because there is no triconsumer.
         */
        @FunctionalInterface
        public interface OnPMessageCallback {
            public void accept(String pattern, String channel, String message);
        }

        private final List<BiConsumer<String,String>> onMessageCallbacks
                = new CopyOnWriteArrayList<>();
        private final List<OnPMessageCallback> onPMessageCallbacks
                = new CopyOnWriteArrayList<>();
        private final List<BiConsumer<String,Integer>> onSubscribeCallbacks
                = new CopyOnWriteArrayList<>();
        private final List<BiConsumer<String,Integer>> onUnsubscribeCallbacks
                = new CopyOnWriteArrayList<>();
        private final List<BiConsumer<String,Integer>> onPSubscribeCallbacks
                = new CopyOnWriteArrayList<>();
        private final List<BiConsumer<String,Integer>> onPUnsubscribeCallbacks
                = new CopyOnWriteArrayList<>();

        /**
         * Implementation of +onMessage+. Forwards the call to the registered callbacks.
         *
         * @see org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub#addOnMessageListener(java.util.function.BiConsumer)
         */
        @Override
        public void onMessage(String channel, String message) {
            onMessageCallbacks.stream().forEach(callback -> callback.accept(channel, message));
        }

        /**
         * Add a callback for +onMessage+.
         *
         * @param callback
         *        The callback to add. The first argument is the
         *        channel, the second is the message.
         * @see org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub#onMessage(String, String)
         */
        public void addOnMessageListener(BiConsumer<String, String> callback) {
            onMessageCallbacks.add(callback);
        }

        /**
         * Implementation of +onPMessage+. Forwards the call to the registered callbacks.
         *
         * @see org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub#addOnPMessageListener(org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub.OnPMessageCallback)
         */
        @Override
        public void onPMessage(String pattern, String channel, String message) {
            onPMessageCallbacks.stream().forEach(callback -> callback.accept(pattern, channel, message));
        }

        /**
         * Add a callback for +onPMessage+.
         *
         * @param callback
         *        The callback to add. The first argument is the
         *        pattern is the channel, the second is the channel,
         *        and the third is the message.
         * @see org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub#onPMessage(String, String, String)
         */
        public void addOnPMessageListener(OnPMessageCallback callback) {
            onPMessageCallbacks.add(callback);
        }

        /**
         * Implementation of +onSubscribe+. Forwards the call to the registered callbacks.
         *
         * @see org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub#addOnSubscribeListener(java.util.function.BiConsumer)
         */
        @Override
        public void onSubscribe(String channel, int subscribedChannels) {
            onSubscribeCallbacks.stream().forEach(callback -> callback.accept(channel, subscribedChannels));
        }

        /**
         * Add a callback for +onSubscribe+
         *
         * @param callback
         *        The callback to add. The first argument is the channel,
         *        the second is the number of subscribed channels.
         * @see org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub#onSubscribe(String, int)
         */
        public void addOnSubscribeListener(BiConsumer<String, Integer> callback) {
            onSubscribeCallbacks.add(callback);
        }

        /**
         * Implementation of +onUnsubscribe+. Forwards the call to the registered callbacks.
         *
         * @see org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub#addOnUnsubscribeListener(java.util.function.BiConsumer)
         */
        @Override
        public void onUnsubscribe(String channel, int subscribedChannels) {
            onUnsubscribeCallbacks.stream().forEach(callback -> callback.accept(channel, subscribedChannels));
        }

        /**
         * Add a callback for +onUnsubscribe+
         *
         * @param callback
         *        The callback to add. The first argument is the channel,
         *        the second is the number of subscribed channels.
         * @see org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub#onUnsubscribe(String, int)
         */
        public void addOnUnsubscribeListener(BiConsumer<String, Integer> callback) {
            onUnsubscribeCallbacks.add(callback);
        }

        /**
         * Implementation of +onPSubscribe+. Forwards the call to the registered callbacks.
         *
         * @see org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub#addOnPSubscribeListener(java.util.function.BiConsumer)
         */
        @Override
        public void onPSubscribe(String pattern, int subscribedChannels) {
            onPSubscribeCallbacks.stream().forEach(callback -> callback.accept(pattern, subscribedChannels));
        }

        /**
         * Add a callback for +onPSubscribe+
         *
         * @param callback
         *        The callback to add. The first argument is the pattern,
         *        the second is the number of subscribed channels.
         * @see org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub#onPSubscribe(String, int)
         */
        public void addOnPSubscribeListener(BiConsumer<String, Integer> callback) {
            onPSubscribeCallbacks.add(callback);
        }

        /**
         * Implementation of +onPUnsubscribe+. Forwards the call to the registered callbacks.
         *
         * @see org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub#addOnPUnsubscribeListener(java.util.function.BiConsumer)
         */
        @Override
        public void onPUnsubscribe(String pattern, int subscribedChannels) {
            onPUnsubscribeCallbacks.stream().forEach(callback -> callback.accept(pattern, subscribedChannels));
        }

        /**
         * Add a callback for +onPUnsubscribe+
         *
         * @param callback
         *        The callback to add. The first argument is the pattern,
         *        the second is the number of subscribed channels.
         * @see org.ulyssis.ipp.utils.JedisHelper.CallBackPubSub#onPUnsubscribe(String, int)
         */
        public void addOnPUnsubscribeListener(BiConsumer<String, Integer> callback) {
            onPUnsubscribeCallbacks.add(callback);
        }
    }
}
shared/src/main/java/org/ulyssis/ipp/status/StatusReporter.java
public final class StatusReporter {
    private static final Logger LOG = LogManager.getLogger(StatusReporter.class);
    
    private final Jedis jedis;
    private final String statusChannel;

    /**
     * Create a new status reporter for the given Redis URI and status channel.
     *
     * @param redisURI
     *        The URI for the Redis instance to report the status on. This should
     *        be the "own" Redis instance.
     * @param statusChannel
     *        The channel to broadcast the status messages to. This channel is
     *        made specific to the database, so if the `redisUri`
     *        contains database `N`, and the channel is `status`,
     *        then messages will be broadcast on channel `status:N`.
     * @see org.ulyssis.ipp.utils.JedisHelper#dbLocalChannel(String, java.net.URI)
     */
    public StatusReporter(URI redisURI, String statusChannel) {
        this.jedis = JedisHelper.get(redisURI);
        this.statusChannel = JedisHelper.dbLocalChannel(statusChannel, redisURI);
    }

    /**
     * Broadcast a status message.
     *
     * @param message
     *        The message to broadcast
     * @return True if the broadcast was successful, false if the broadcast was unsuccessful.
     *         Failure to broadcast will be logged.
     */
    public synchronized boolean broadcast(StatusMessage message) {
        if (jedis == null)
            return false;
        try {
            jedis.publish(statusChannel.getBytes(), Serialization.getJsonMapper().writeValueAsBytes(message));
            return true;
        } catch (JsonProcessingException e) {
            LOG.error("Error processing message {}", message.getDetails(), e);
            return false;
        } catch (JedisConnectionException e) {
            LOG.error("Couldn't connect to Redis when sending: {}", message.getDetails(), e);
            return false;
        }
    }
}
shared/src/main/java/org/ulyssis/ipp/config/Config.java
public class Config {
    private static final Logger LOG = LogManager.getLogger(Config.class);

    private double trackLength;
    private ImmutableList<ReaderConfig> readers = ImmutableList.of();
    private ImmutableList<URI> speedwayURIs = ImmutableList.of();
    private ImmutableList<Team> teams = ImmutableList.of();
    private long retryInterval = 5000L;
    private long minUpdateInterval = 5000L;
    private long maxUpdateInterval = 30000L;
    private String statusChannel = "status";
    private String updateChannel = "update";
    private String updatesList = "updates";
    private String updatesSet = "updates";
    private String controlChannel = "control";

    /**
     * Private constructor used by Jackson
     */
    private Config() {
    }
    
    /**
     * Create a configuration from the given configuration file.
     * 
     * Expects the configuration file to be UTF-8 formatted.
     */
    public static Optional<Config> fromConfigurationFile(Path configFile) {
        try {
            byte[] config = Files.readAllBytes(configFile);
            Charset charset = Charset.forName("utf-8");
            String configString = charset.decode(ByteBuffer.wrap(config)).toString();
            return fromConfigurationString(configString);
        } catch (IOException e) {
            LOG.error("Error reading configuration file: {}", configFile, e);
            return Optional.empty();
        }
    }
    
    /**
     * Create a configuration from the given JSON configuration string.
     */
    public static Optional<Config> fromConfigurationString(String configuration) {
        ObjectMapper mapper = new ObjectMapper();
        mapper.findAndRegisterModules();
        try {
            Config result = mapper.readValue(configuration, Config.class);
            return Optional.of(result);
        } catch (IOException e) {
            LOG.error("Error reading configuration", e);
            return Optional.empty();
        }
    }

    /**
     * Get the track length in meters.
     */
    public double getTrackLength() {
        return trackLength;
    }

    public ImmutableList<ReaderConfig> getReaders() {
        return readers;
    }
    public ReaderConfig getReader(int readerId) {
        return this.readers.get(readerId);
    }
    public int getNbReaders() {
        return readers.size();
    }

    public ImmutableList<URI> getSpeedwayURIs() {
        return speedwayURIs;
    }
    /**
     * Get the URI of the Impinj Speedway corresponding
     * to the reader with the given id. This id ranges from 0 to
     * getSpeedwayURIs.size() - 1
     */
    public URI getSpeedwayURI(int readerId) {
        return speedwayURIs.get(readerId);
    }
    public int getNbSpeedways() {
        return speedwayURIs.size();
    }

    /**
     * The retry interval for connections in ms.
     */
    public long getRetryInterval() {
        return retryInterval;
    }
    
    /**
     * The minimum amount of time in ms between two updates, any
     * update within this amount of time is ignored.
     */
    public long getMinUpdateInterval() {
        return minUpdateInterval;
    }
    
    /**
     * The maximum time in ms to wait for an update before issuing a warning.
     */
    public long getMaxUpdateInterval() {
        return maxUpdateInterval;
    }
    
    /**
     * Gets the Redis channel name on which status updates are posted.
     *
     * Defaults to "status".
     *
     * Generally, the db id will be added to the prefix, because
     * pubsub channels are not database-specific in Redis.
     */
    public String getStatusChannel() {
        return statusChannel;
    }
    
    /**
     * The prefix of the channel where the reader announces new updates.
     *
     * This prefix defaults to "update". Generally, the db id will be added
     * to the prefix, because if multiple readers would use the same Redis
     * instance (e.g. for testing purposes), these would collide, because
     * pubsub channels are not database-specific.
     */
    public String getUpdateChannel() {
        return updateChannel;
    }

    /**
     * The prefix of the channel where an entity in the system receives control messages.
     *
     * This defaults to "control". Generally, the db id will be added to this prefix,
     * because pubsub channels are not database-specific in Redis.
     */
    public String getControlChannel() {
        return controlChannel;
    }

    /**
     * The name of the list where readers push their updates on.
     *
     * Defaults to "updates".
     */
    public String getUpdatesList() {
        return updatesList;
    }

    /**
     * The name of the set in which updates are stored in the processor.
     */
    public String getUpdatesSet() {
        return updatesSet;
    }

    /**
     * The teams, with team number and name.
     */
    public ImmutableList<Team> getTeams() {
        return teams;
    }

    // TODO: Maybe find some other solution?
    private static Config currentConfig;

    public static void setCurrentConfig(Config config) {
        currentConfig = config;
    }

    public static Config getCurrentConfig() {
        return currentConfig;
    }
}
shared/src/main/java/org/ulyssis/ipp/control/commands/SetEndTimeCommand.java
@JsonTypeName("SetEndTime")
public final class SetEndTimeCommand extends Command {
    public SetEndTimeCommand() {
        super();
    }

    public SetEndTimeCommand(Instant endTime) {
        super(endTime);
    }

    @JsonCreator
    private SetEndTimeCommand(@JsonProperty("commandId") String commandId,
                              @JsonProperty("time") Instant time) {
        super(commandId, time);
    }
}
Processor/src/main/java/org/ulyssis/ipp/snapshot/CorrectionEvent.java
@JsonTypeName("Correction")
public final class CorrectionEvent extends Event {
    private int teamNb;
    private int correction;

    /**
     * Constructor for Jackson
     */
    @SuppressWarnings("unused")
    private CorrectionEvent() {
        super(Instant.MIN);
    }

    /**
     * Create an event representing a correction of score.
     *
     * @param time
     *        The time at which the corection is performed.
     * @param teamNb
     *        The number of the team to correct the score of.
     * @param correction
     *        The number of laps that should be added (positive)
     *        or removed (negative)
     */
    public CorrectionEvent(Instant time, int teamNb, int correction) {
        super(time);
        this.teamNb = teamNb;
        this.correction = correction;
    }

    @SuppressWarnings("unused")
    public int getTeamNb() {
        return teamNb;
    }

    @SuppressWarnings("unused")
    public int getCorrection() {
        return correction;
    }

    protected Snapshot doApply(Snapshot snapshot) {
        TeamStates oldTeamStates = snapshot.getTeamStates();
        Optional<TeamState> oldTeamState = oldTeamStates.getStateForTeam(teamNb);
        TeamState newTeamState;
        if (oldTeamState.isPresent()) {
            newTeamState = oldTeamState.get().addCorrection(correction);
        } else {
            newTeamState = new TeamState().addCorrection(correction);
        }
        return Snapshot.builder(getTime(), snapshot)
                .withTeamStates(snapshot.getTeamStates().setStateForTeam(teamNb, newTeamState))
                .build();
    }

    public static CorrectionEvent fromCommand(Command command) {
        assert(command instanceof CorrectionCommand);
        CorrectionCommand cmd = (CorrectionCommand) command;
        return new CorrectionEvent(cmd.getTime(), cmd.getTeamNb(), cmd.getCorrection());
    }

    @Override
    public boolean isRemovable() {
        return true;
    }
}
shared/src/main/java/org/ulyssis/ipp/control/commands/SetUpdateFrequencyCommand.java
@JsonTypeName("SetUpdateFrequency")
public final class SetUpdateFrequencyCommand extends Command {
    private final int updateFrequency;

    public SetUpdateFrequencyCommand(int updateFrequency) {
        super();
        this.updateFrequency = updateFrequency;
    }

    @JsonCreator
    private SetUpdateFrequencyCommand(Instant time, int updateFrequency) {
        super(time);
        this.updateFrequency = updateFrequency;
    }

    public int getUpdateFrequency() {
        return updateFrequency;
    }
}
shared/src/main/java/org/ulyssis/ipp/utils/Serialization.java
public final class Serialization {
    private static final ObjectMapper jsonMapper;

    static {
        jsonMapper = new ObjectMapper();
        jsonMapper.findAndRegisterModules();
    }

    // Static methods only! No instances!
    private Serialization() {
    }

    /**
     * Get a JSON mapper, with all modules registered.
     *
     * This is equivalent to:
     *
     * [source,java]
     * --
     * jsonMapper = new ObjectMapper();
     * jsonMapper.findAndRegisterModules();
     * --
     *
     * @return A JSON ObjectMapper
     */
    public static ObjectMapper getJsonMapper() {
        return jsonMapper;
    }
}
shared/src/main/java/org/ulyssis/ipp/control/commands/RemoveTagCommand.java
@JsonTypeName("RemoveTag")
public final class RemoveTagCommand extends TagCommand {
    public RemoveTagCommand(TagId tag, int teamNb) {
        super(tag, teamNb);
    }

    public RemoveTagCommand(Instant time, TagId tag, int teamNb) {
        super(time, tag, teamNb);
    }

    @JsonCreator
    private RemoveTagCommand(@JsonProperty("commandId") String commandId,
                             @JsonProperty("time") Instant time,
                             @JsonProperty("tag") TagId tag,
                             @JsonProperty("teamNb") int teamNb) {
        super(commandId, time, tag, teamNb);
    }
}
shared/src/main/java/org/ulyssis/ipp/control/commands/CorrectionCommand.java
@JsonTypeName("Correction")
public class CorrectionCommand extends Command {
    private final int teamNb;
    private final int correction;

    /**
     * Create a CorrectionCommand
     * @param teamNb
     *        The team number to correct
     * @param correction
     *        The correction in nb. of laps. Positive adds laps,
     *        negative removes laps.
     */
    public CorrectionCommand(int teamNb, int correction) {
        super();
        this.teamNb = teamNb;
        this.correction = correction;
    }

    public CorrectionCommand(Instant time, int teamNb, int correction) {
        super(time);
        this.teamNb = teamNb;
        this.correction = correction;
    }

    @JsonCreator
    private CorrectionCommand(@JsonProperty("commandId") String commandId,
                              @JsonProperty("time") Instant time,
                              @JsonProperty("teamNb") int teamNb,
                              @JsonProperty("correction") int correction) {
        super(commandId, time);
        this.teamNb = teamNb;
        this.correction = correction;
    }

    public int getTeamNb() {
        return teamNb;
    }

    public int getCorrection() {
        return correction;
    }
}
Processor/src/main/java/org/ulyssis/ipp/snapshot/AddTagEvent.java
@JsonTypeName("AddTag")
public final class AddTagEvent extends TagEvent {
    @JsonCreator
    public AddTagEvent(
            @JsonProperty("time") Instant time,
            @JsonProperty("tag") TagId tag,
            @JsonProperty("teamNb") int teamNb) {
        super(time, tag, teamNb);
    }

    protected Snapshot doApply(Snapshot snapshot) {
        TeamTagMap newTeamTagMap = snapshot.getTeamTagMap().addTagToTeam(getTag(), getTeamNb());
        return Snapshot.builder(getTime(), snapshot).withTeamTagMap(newTeamTagMap).build();
    }

    public static AddTagEvent fromCommand(Command command) {
        assert(command instanceof AddTagCommand);
        AddTagCommand addTagCommand = (AddTagCommand) command;
        return new AddTagEvent(
            addTagCommand.getTime(),
            addTagCommand.getTag(),
            addTagCommand.getTeamNb()
        );
    }
}
shared/src/main/java/org/ulyssis/ipp/control/handlers/PingHandler.java
public final class PingHandler implements CommandHandler {

    /**
     * Handles the given command, which should be a PingCommand.
     *
     * @param command
     *        The ping command to handle.
     * @param callback
     *        The ping callback, which is always invoked with true.
     */
    @Override
    public void handle(Command command, Consumer<Boolean> callback) {
        callback.accept(true);
    }

    /**
     * Returns PingCommand.class
     *
     * @return PingCommand.class
     */
    @Override
    public Class<? extends Command> getCommandClass() {
        return PingCommand.class;
    }
}
Processor/src/main/java/org/ulyssis/ipp/snapshot/RemoveTagEvent.java
@JsonTypeName("RemoveTag")
public final class RemoveTagEvent extends TagEvent {
    @JsonCreator
    public RemoveTagEvent(
            @JsonProperty("time") Instant time,
            @JsonProperty("tag") TagId tag,
            @JsonProperty("teamNb") int teamNb) {
        super(time, tag, teamNb);
    }

    @Override
    protected Snapshot doApply(Snapshot snapshot) {
        TeamTagMap newTeamTagMap = snapshot.getTeamTagMap().removeTag(getTag());
        return Snapshot.builder(getTime(), snapshot).withTeamTagMap(newTeamTagMap).build();
    }

    public static RemoveTagEvent fromCommand(Command command) {
        assert(command instanceof RemoveTagCommand);
        RemoveTagCommand cmd = (RemoveTagCommand) command;
        return new RemoveTagEvent(cmd.getTime(), cmd.getTag(), cmd.getTeamNb());
    }
}
Processor/src/main/java/org/ulyssis/ipp/snapshot/StatusChangeEvent.java
@JsonTypeName("StatusChange")
public final class StatusChangeEvent extends Event {
    private final Status status;

    @JsonCreator
    public StatusChangeEvent(@JsonProperty("time") Instant time,
                             @JsonProperty("status")Status status) {
        super(time);
        this.status = status;
    }

    public Status getStatus() {
        return status;
    }

    public static StatusChangeEvent fromCommand(Command command) {
        assert(command instanceof SetStatusCommand);
        SetStatusCommand cmd = (SetStatusCommand) command;
        return new StatusChangeEvent(cmd.getTime(), cmd.getStatus());
    }

    @Override
    protected Snapshot doApply(Snapshot before) {
        Snapshot.Builder builder = Snapshot.builder(getTime(), before).withStatus(status);
        if (!before.getStatus().isPublic() && status.isPublic()) {
            builder.withPublicTeamStates(before.getTeamStates());
        }
        return builder.build();
    }

    @Override
    public boolean isRemovable() {
        return true;
    }
}
shared/src/main/java/org/ulyssis/ipp/control/CommandProcessor.java
public final class CommandProcessor implements Runnable {
    private static final Logger LOG = LogManager.getLogger(CommandProcessor.class);

    private final Map<Class<? extends Command>, CommandHandler> commandHandlers = new HashMap<>();
    private final Jedis jedis;
    private final StatusReporter reporter;
    private final String channel;

    private BinaryJedisPubSub listener;

    public CommandProcessor(URI redisUri, String commandChannel, String statusChannel) {
        this(redisUri, JedisHelper.dbLocalChannel(commandChannel, redisUri),
                new StatusReporter(redisUri, statusChannel));
    }

    public CommandProcessor(URI redisUri, String channel, StatusReporter statusReporter) {
        this.jedis = JedisHelper.get(redisUri);
        this.channel = JedisHelper.dbLocalChannel(channel, redisUri);
        this.reporter = statusReporter;
    }

    public void run() {
        jedis.subscribe(createCommandListener(), channel.getBytes());
    }

    private BinaryJedisPubSub createCommandListener() {
        JedisHelper.BinaryCallBackPubSub pubSub = new JedisHelper.BinaryCallBackPubSub();
        listener = pubSub;
        pubSub.addOnMessageListener(this::onCommandMessage);
        return pubSub;
    }

    private void onCommandMessage(byte[] channel, byte[] message) {
        assert (Arrays.equals(channel, this.channel.getBytes()));
        try {
            Command command = Serialization.getJsonMapper().readValue(message, Command.class);
            handleCommand(command);
        } catch (IOException e) {
            LOG.error("Couldn't parse command: {}", new String(message), e);
        }
    }

    public void addHandler(CommandHandler handler) {
        commandHandlers.put(handler.getCommandClass(), handler);
    }

    private void handleCommand(Command command) {
        LOG.debug("Handing command {}: {}", command.getCommandId(), command.getClass().toString());
        if (commandHandlers.containsKey(command.getClass())) {
            commandHandlers.get(command.getClass()).handle(command, notifyCommandExecuted(command));
        } else {
            notifyCommandUnsupported(command);
        }
    }

    private Consumer<Boolean> notifyCommandExecuted(Command command) {
        return (result) -> {
            if (result) {
                notifySuccess(command);
            } else {
                notifyFailure(command);
            }
        };
    }

    private void notifySuccess(Command command) {
        reporter.broadcast(new StatusMessage(StatusMessage.MessageType.COMMAND_COMPLETE, command.getCommandId()));
    }

    private void notifyFailure(Command command) {
        reporter.broadcast(new StatusMessage(StatusMessage.MessageType.COMMAND_FAILED, command.getCommandId()));
    }

    private void notifyCommandUnsupported(Command command) {
        reporter.broadcast(new StatusMessage(StatusMessage.MessageType.COMMAND_UNSUPPORTED, command.getCommandId()));
    }

    public void stop() {
        try {
            listener.unsubscribe();
        } catch (JedisConnectionException ignored) {
        }
    }
}
Processor/src/main/java/org/ulyssis/ipp/control/handlers/EventCommandHandler.java
public final class EventCommandHandler<EventT extends Event> implements CommandHandler {
    private final Class<? extends Command> clazz;
    private final Function<Command, EventT> toEvent;
    private final BiConsumer<Event, Consumer<Boolean>> processCallback;

    public EventCommandHandler(Class<? extends Command> clazz,
                               Function<Command, EventT> toEvent,
                               BiConsumer<Event, Consumer<Boolean>> processCallback) {
        this.clazz = clazz;
        this.toEvent = toEvent;
        this.processCallback = processCallback;
    }

    @Override
    public void handle(Command command, Consumer<Boolean> callback) {
        Event event = toEvent.apply(command);
        processCallback.accept(event, callback);
    }

    @Override
    public Class<? extends Command> getCommandClass() {
        return clazz;
    }
}
Processor/src/main/java/org/ulyssis/ipp/snapshot/EndEvent.java
@JsonTypeName("End")
public final class EndEvent extends Event {
    /**
     * Constructor for Jackson
     */
    @SuppressWarnings("unused")
    private EndEvent() {
        super(Instant.MIN);
    }

    public EndEvent(Instant time) {
        super(time);
    }

    protected Snapshot doApply(Snapshot snapshot) {
        return Snapshot.builder(getTime(), snapshot).withEndTime(getTime()).build();
    }

    public static EndEvent fromCommand(Command command) {
        assert(command instanceof SetEndTimeCommand);
        SetEndTimeCommand cmd = (SetEndTimeCommand) command;
        return new EndEvent(cmd.getTime());
    }

    @Override
    public boolean isUnique() {
        return true;
    }
}
shared/src/main/java/org/ulyssis/ipp/control/commands/SetStartTimeCommand.java
@JsonTypeName("SetStartTime")
public final class SetStartTimeCommand extends Command {
    public SetStartTimeCommand() {
        super();
    }

    public SetStartTimeCommand(Instant startTime) {
        super(startTime);
    }

    @JsonCreator
    private SetStartTimeCommand(@JsonProperty("commandId") String commandId,
                                @JsonProperty("time") Instant time) {
        super(commandId, time);
    }
}
shared/src/main/java/org/ulyssis/ipp/control/commands/AddTagCommand.java
@JsonTypeName("AddTag")
public final class AddTagCommand extends TagCommand {

    /**
     * Create an AddTagCommand for the given tag and team number.
     *
     * The time is set to be the current time.
     *
     * @param tag
     *        The tag to add.
     * @param teamNb
     *        The team number to add the tag for.
     */
    public AddTagCommand(TagId tag, int teamNb) {
        super(tag, teamNb);
    }

    /**
     * Create an AddTagCommand for the given tag and team number at the given time.
     *
     * @param time
     *        The time when to add the tag, this can be in the future for
     *        an anticipated adding of a tag, or in the past, to add a tag
     *        afterwards (e.g. as a correction)
     * @param tag
     *        The tag to add.
     * @param teamNb
     *        The team number to add the tag for.
     */
    public AddTagCommand(Instant time, TagId tag, int teamNb) {
        super(time, tag, teamNb);
    }

    /*
     * Only for deserialization.
     */
    @JsonCreator
    private AddTagCommand(@JsonProperty("commandId") String commandId,
                          @JsonProperty("time") Instant time,
                          @JsonProperty("tag") TagId tag,
                          @JsonProperty("teamNb") int teamNb) {
        super(commandId, time, tag, teamNb);
    }
}
shared/src/main/java/org/ulyssis/ipp/control/commands/SetStatusMessageCommand.java
@JsonTypeName("SetStatusMessage")
public class SetStatusMessageCommand extends Command {
    private final String message;

    public SetStatusMessageCommand(String message) {
        super();
        this.message = message;
    }

    public SetStatusMessageCommand(Instant time, String message) {
        super(time);
        this.message = message;
    }

    @JsonCreator
    private SetStatusMessageCommand(@JsonProperty("commandId") String commandId,
                                    @JsonProperty("time") Instant time,
                                    @JsonProperty("message") String message) {
        super(commandId, time);
        this.message = message;
    }

    public String getMessage() {
        return message;
    }
}
shared/src/main/java/org/ulyssis/ipp/config/Team.java
public final class Team {
    private static final Logger LOG = LogManager.getLogger(Team.class);

    private int teamNb;
    private String name;
    private ImmutableList<TagId> tags;

    @SuppressWarnings("unused")
    private Team() {
        this.teamNb = 0;
        this.name = "";
        tags = ImmutableList.of();
    }

    public Team(int teamNb, String name) {
        this.teamNb = teamNb;
        this.name = name;
        this.tags = ImmutableList.of();
    }

    public Team(int teamNb, String name, ImmutableList<TagId> tags) {
        this.teamNb = teamNb;
        this.name = name;
        this.tags = tags;
    }

    public int getTeamNb() {
        return teamNb;
    }

    public String getName() {
        return name;
    }

    public ImmutableList<TagId> getTags() {
        return tags;
    }
}
shared/src/main/java/org/ulyssis/ipp/config/ReaderConfig.java
public class ReaderConfig {
    private URI uri;
    private Type type = Type.LLRP;
    private double position;
    private List<SimulatedTeam> simulatedTeams = new ArrayList<>();

    public static class SimulatedTeam {
        private final TagId tag;
        private final long lapTime;

        @JsonCreator
        public SimulatedTeam(
                @JsonProperty("tag") TagId tag,
                @JsonProperty("lapTime") long lapTime) {
            this.tag = tag;
            this.lapTime = lapTime;
        }

        public TagId getTag() {
            return tag;
        }

        public long getLapTime() {
            return lapTime;
        }
    }

    public enum Type {
        LLRP,
        SIMULATOR
    }

    /**
     * Private constructor used by Jackson
     */
    @SuppressWarnings("unused")
    private ReaderConfig() {
    }

    public URI getURI() {
        return uri;
    }
    
    /**
     * Private setter used by Jackson
     */
    @SuppressWarnings("unused")
    private void setURI(URI uri) {
        this.uri = uri;
    }

    public double getPosition() {
        return position;
    }

    /**
     * Private setter used by Jackson.
     */
    @SuppressWarnings("unused")
    private void setPosition(double position) {
        this.position = position;
    }

    public Type getType() {
        return type;
    }

    /**
     * Private setter used by Jackson.
     */
    @SuppressWarnings("unused")
    private void setType(Type type) {
        this.type = type;
    }

    public List<SimulatedTeam> getSimulatedTeams() {
        return Collections.unmodifiableList(simulatedTeams);
    }

    public SimulatedTeam getSimulatedTeam(int id) {
        return simulatedTeams.get(id);
    }

    /**
     * Private setter used by Jackson.
     */
    @SuppressWarnings("unused")
    private void setSimulatedTeams(List<SimulatedTeam> simulatedTeams) {
        this.simulatedTeams = simulatedTeams;
    }
}
shared/src/main/java/org/ulyssis/ipp/status/StatusMessage.java
public final class StatusMessage {
    private MessageType type;
    private String details;

    public enum MessageType {
        /**
         * The reader sends this message when no updates have been received for a while
         */
        NO_UPDATES,
        /**
         * Notifies that the system has started up
         */
        STARTED_UP,
        /**
         * Notifies that the startup has failed
         */
        STARTUP_FAILURE,
        /**
         * Notifies that the system is being shut down
         */
        SHUTDOWN,
        /**
         * Signals that the command given executed successfully.
         *
         * The details field will contain the command id.
         *
         * @see org.ulyssis.ipp.control
         */
        COMMAND_COMPLETE,
        /**
         * Signals that no command handler is installed for the given command
         *
         * The details field will contain the command id.
         *
         * @see org.ulyssis.ipp.control
         */
        COMMAND_UNSUPPORTED,
        /**
         * Signals that the command failed to execute
         *
         * The details field will contain the command id.
         *
         * @see org.ulyssis.ipp.control
         */
        COMMAND_FAILED,
        /**
         * Signals that the processor's snapshot has been updated
         */
        NEW_SNAPSHOT,
        /**
         * Signals a miscellaneous error. The details field will contain more information
         * about the error.
         */
        MISC_ERROR
    }

    /**
     * Create a new status message with the given type and details.
     *
     * This message is to be broadcast by a StatusReporter to the
     * status channel.
     *
     * @param type
     *        The type of the status
     * @param details
     *        The details. The form of this field depends
     *        on the type of message.
     * @see org.ulyssis.ipp.status.StatusReporter
     */
    @JsonCreator
    public StatusMessage(@JsonProperty("type") MessageType type,
                         @JsonProperty("details") String details) {
        this.type = type;
        this.details = details;
    }

    public MessageType getType() {
        return type;
    }

    public String getDetails() {
        return details;
    }
}
Processor/src/main/java/org/ulyssis/ipp/snapshot/Event.java
@JsonTypeInfo(use=JsonTypeInfo.Id.NAME, include=JsonTypeInfo.As.PROPERTY, property="type")
@JsonSubTypes({ @JsonSubTypes.Type(value=StartEvent.class),
                @JsonSubTypes.Type(value=EndEvent.class),
                @JsonSubTypes.Type(value=AddTagEvent.class),
                @JsonSubTypes.Type(value=RemoveTagEvent.class),
                @JsonSubTypes.Type(value=CorrectionEvent.class),
                @JsonSubTypes.Type(value=TagSeenEvent.class),
                @JsonSubTypes.Type(value=IdentityEvent.class),
                @JsonSubTypes.Type(value=MessageEvent.class),
                @JsonSubTypes.Type(value=StatusChangeEvent.class)})
public abstract class Event {
    private static final Logger LOG = LogManager.getLogger(Event.class);

    @JsonIgnore
    private long id = -1;
    @JsonIgnore
    private boolean removed = false;

    private Instant time;

    protected Event(Instant time) {
        this.time = time;
    }

    @JsonIgnore
    public final Optional<Long> getId() {
        if (id != -1) return Optional.of(id);
        else return Optional.empty();
    }

    @JsonIgnore
    public final boolean isRemoved() {
        if (id == -1) throw new IllegalStateException("Trying to request removed state of event not in db");
        return removed;
    }

    public final Instant getTime() {
        return time;
    }

    /**
     * Determines whether this event should be isUnique, defaults to false
     *
     * @return whether this event should be isUnique (default implementation = false)
     */
    @JsonIgnore
    public boolean isUnique() {
        return false;
    }

    /**
     * Determines whether this event can be removed or undone.
     *
     * @return whether this event can be removed or undone (default implementation = isUnique())
     */
    @JsonIgnore
    public boolean isRemovable() {
        return isUnique();
    }

    /**
     * Apply this event to a snapshot, yielding the new snapshot
     */
    protected abstract Snapshot doApply(Snapshot before);

    public final Snapshot apply(Snapshot before) {
        assert !removed;
        Snapshot result = doApply(before);
        if (result == before && before.getEventId().isPresent()) {
            // We need to copy it anyway
            result = Snapshot.builder(getTime(), before).build();
        }
        result.eventId = this.id;
        return result;
    }

    // TODO: How to deal with deserialization problem?
    public static List<Event> loadAll(Connection connection) throws SQLException, IOException {
        String statement = "SELECT \"id\", \"data\", \"removed\" FROM \"events\" ORDER BY \"time\" ASC, \"id\" ASC";
        List<Event> events = new ArrayList<>();
        try (Statement stmt = connection.createStatement();
             ResultSet result = stmt.executeQuery(statement)) {
            while (result.next()) {
                String evString = result.getString("data");
                Event event = Serialization.getJsonMapper().readValue(evString, Event.class);
                event.id = result.getLong("id");
                event.removed = result.getBoolean("removed");
                events.add(event);
            }
        }
        return events;
    }

    public static Optional<Event> loadUnique(Connection connection, Class<? extends Event> eventType) throws SQLException, IOException {
        String statement = "SELECT \"id\", \"data\" FROM \"events\" WHERE \"type\" = ? AND \"removed\" = false";
        try (PreparedStatement stmt = connection.prepareStatement(statement)) {
            stmt.setString(1, eventType.getSimpleName());
            ResultSet result = stmt.executeQuery();
            if (result.next()) {
                String evString = result.getString("data");
                Event event = Serialization.getJsonMapper().readValue(evString, Event.class);
                event.id = result.getLong("id");
                event.removed = false;
                return Optional.of(event);
            } else {
                return Optional.empty();
            }
        }
    }

    public static Optional<Event> load(Connection connection, long id) throws SQLException, IOException {
        try (PreparedStatement statement =
                connection.prepareStatement(
                        "SELECT \"data\",\"removed\" FROM \"events\" WHERE \"id\"=?")) {
            statement.setLong(1, id);
            ResultSet result = statement.executeQuery();
            if (result.next()) {
                String evString = result.getString("data");
                Event event = Serialization.getJsonMapper().readValue(evString, Event.class);
                event.id = id;
                event.removed = result.getBoolean("removed");
                return Optional.of(event);
            } else {
                return Optional.empty();
            }
        }
    }

    public static List<Event> loadFrom(Connection connection, Instant time) throws SQLException, IOException {
        String statement = "SELECT \"id\",\"data\",\"removed\" FROM \"events\" " +
                "WHERE \"time\" >= ? ORDER BY \"time\" ASC, \"id\" ASC";
        List<Event> events = new ArrayList<>();
        try (PreparedStatement stmt = connection.prepareStatement(statement)) {
            stmt.setTimestamp(1, Timestamp.from(time));
            ResultSet rs = stmt.executeQuery();
            while (rs.next()) {
                String evString = rs.getString("data");
                Event event = Serialization.getJsonMapper().readValue(evString, Event.class);
                event.id = rs.getLong("id");
                event.removed = rs.getBoolean("removed");
                events.add(event);
            }
        }
        return events;
    }

    public static List<Event> loadAfter(Connection connection, Instant time, long id) throws SQLException, IOException {
        String statement = "SELECT \"id\",\"data\",\"removed\" FROM \"events\" " +
                "WHERE \"time\" > ? OR (\"time\" = ? AND \"id\" > ?) ORDER BY \"time\" ASC, \"id\" ASC";
        List<Event> events = new ArrayList<>();
        try (PreparedStatement stmt = connection.prepareStatement(statement)) {
            stmt.setTimestamp(1, Timestamp.from(time));
            stmt.setTimestamp(2, Timestamp.from(time));
            stmt.setLong(3, id);
            LOG.debug("Executing query: {}", stmt);
            ResultSet rs = stmt.executeQuery();
            while (rs.next()) {
                String evString = rs.getString("data");
                Event event = Serialization.getJsonMapper().readValue(evString, Event.class);
                event.id = rs.getLong("id");
                event.removed = rs.getBoolean("removed");
                events.add(event);
            }
        }
        LOG.debug("Loaded {} events", events.size());
        return events;
    }

    public void save(Connection connection) throws SQLException {
        if (id != -1) return;
        try (PreparedStatement statement = connection.prepareStatement(
                    "INSERT INTO \"events\" (\"time\",\"type\",\"data\",\"removed\") " +
                            "VALUES (?,?,?,?)", Statement.RETURN_GENERATED_KEYS)) {
            statement.setTimestamp(1, Timestamp.from(time));
            String serialized;
            try {
                serialized = Serialization.getJsonMapper().writeValueAsString(this);
            } catch (JsonProcessingException e) {
                assert false;
                throw new IllegalStateException(e); // TODO(Roel): is this appropriate?
            }
            statement.setString(2, this.getClass().getSimpleName());
            statement.setString(3, serialized);
            statement.setBoolean(4, removed);
            statement.executeUpdate();
            ResultSet keys = statement.getGeneratedKeys();
            keys.next();
            this.id = keys.getLong(1);
        }
    }

    public void setRemoved(Connection connection, boolean removed) throws SQLException {
        if (!isRemovable()) {
            assert false; // This is a programming error
            return;
        }
        PreparedStatement statement =
                connection.prepareStatement(
                        "UPDATE \"events\" SET \"removed\"=? WHERE \"id\"=?");
        statement.setBoolean(1, removed);
        statement.setLong(2, id);
        boolean result = statement.execute();
        assert(!result);
        this.removed = true;
    }
}
Processor/src/main/java/org/ulyssis/ipp/snapshot/UpdateFrequencyChangeEvent.java
@JsonTypeName("UpdateFrequencyChange")
public final class UpdateFrequencyChangeEvent extends Event {
    private final int updateFrequency;

    @JsonCreator
    public UpdateFrequencyChangeEvent(Instant time, int updateFrequency) {
        super(time);
        this.updateFrequency = updateFrequency;
    }

    @Override
    protected Snapshot doApply(Snapshot before) {
        return Snapshot.builder(getTime(), before).withUpdateFrequency(updateFrequency).build();
    }

    public int getUpdateFrequency() {
        return updateFrequency;
    }

    public static UpdateFrequencyChangeEvent fromCommand(Command command) {
        assert(command instanceof SetUpdateFrequencyCommand);
        SetUpdateFrequencyCommand cmd = (SetUpdateFrequencyCommand) command;
        return new UpdateFrequencyChangeEvent(cmd.getTime(), cmd.getUpdateFrequency());
    }
}


Question: import com.fasterxml.jackson.core.JsonProcessingException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ulyssis.ipp.config.Config;
import org.ulyssis.ipp.config.ReaderConfig;
import org.ulyssis.ipp.config.Team;
import org.ulyssis.ipp.control.CommandProcessor;
import org.ulyssis.ipp.control.commands.AddTagCommand;
import org.ulyssis.ipp.control.commands.CorrectionCommand;
import org.ulyssis.ipp.control.commands.RemoveTagCommand;
import org.ulyssis.ipp.control.commands.SetEndTimeCommand;
import org.ulyssis.ipp.control.commands.SetStartTimeCommand;
import org.ulyssis.ipp.control.commands.SetStatusCommand;
import org.ulyssis.ipp.control.commands.SetStatusMessageCommand;
import org.ulyssis.ipp.control.commands.SetUpdateFrequencyCommand;
import org.ulyssis.ipp.control.handlers.EventCommandHandler;
import org.ulyssis.ipp.control.handlers.PingHandler;
import org.ulyssis.ipp.snapshot.Snapshot;
import org.ulyssis.ipp.snapshot.AddTagEvent;
import org.ulyssis.ipp.snapshot.CorrectionEvent;
import org.ulyssis.ipp.snapshot.EndEvent;
import org.ulyssis.ipp.snapshot.Event;
import org.ulyssis.ipp.snapshot.MessageEvent;
import org.ulyssis.ipp.snapshot.RemoveTagEvent;
import org.ulyssis.ipp.snapshot.StartEvent;
import org.ulyssis.ipp.snapshot.StatusChangeEvent;
import org.ulyssis.ipp.snapshot.UpdateFrequencyChangeEvent;
import org.ulyssis.ipp.status.StatusMessage;
import org.ulyssis.ipp.status.StatusReporter;
import org.ulyssis.ipp.utils.JedisHelper;
import org.ulyssis.ipp.utils.Serialization;
import org.ulyssis.ipp.TagId;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.exceptions.JedisConnectionException;
import java.io.IOException;
import java.net.URI;
import java.sql.*;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import static org.ulyssis.ipp.processor.Database.ConnectionFlags.READ_ONLY;
import static org.ulyssis.ipp.processor.Database.ConnectionFlags.READ_WRITE;
/*
 * Copyright (C) 2014-2015 ULYSSIS VZW
 *
 * This file is part of i++.
 * 
 * i++ is free software: you can redistribute it and/or modify
 * it under the terms of version 3 of the GNU Affero General Public License
 * as published by the Free Software Foundation. No other versions apply.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 * 
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>
 */
package org.ulyssis.ipp.processor;




/**
 * The processor, well, pulls and processes the updates that come in from the readers.
 *
 * It maintains the state of the system: the number of laps run for each team, their
 * forecasts, etc.
 */
public final class Processor implements Runnable {
    private static final Logger LOG = LogManager.getLogger(Processor.class);

    private final BlockingQueue<Event> eventQueue;
    private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();

    private final ConcurrentMap<Event, Consumer<Boolean> > eventCallbacks;

    private final StatusReporter statusReporter;
    private final CommandProcessor commandProcessor;
    private final List<Consumer<Processor>> onStartedCallbacks;

    /**
     * The listeners responsible for fetching results from all registered readers
     */
    private final List<ReaderListener> readerListeners;

    private final List<Thread> threads;

    private Snapshot snapshot;

    public Processor(final ProcessorOptions options) {
        Database.setDatabaseURI(options.getDatabaseUri());
        try (Connection connection = Database.createConnection(EnumSet.of(READ_WRITE))) {
            if (options.shouldClearDb()) {
                Database.clearDb(connection);
            }
            Database.initDb(connection);
            connection.commit();
        } catch (SQLException e) {
            LOG.fatal("Error initializing database!", e);
        }
        URI uri = options.getRedisUri();
        this.eventQueue = new LinkedBlockingQueue<>();
        this.eventCallbacks  = new ConcurrentHashMap<>();
        this.onStartedCallbacks = new CopyOnWriteArrayList<>();
        this.readerListeners = new ArrayList<>();
        this.threads = new ArrayList<>();
        // TODO: Move status reporting and processing of commands to ZeroMQ?
        // Also: post some stuff to a log in the db?
        this.statusReporter = new StatusReporter(uri, Config.getCurrentConfig().getStatusChannel());
        this.commandProcessor = new CommandProcessor(uri, Config.getCurrentConfig().getControlChannel(), statusReporter);
        initCommandProcessor();
        snapshot = new Snapshot(Instant.EPOCH);
        if (!restoreFromDb()) {
            registerInitialTags();
        }
    }

    /**
     * Restore the state from the database
     *
     * @return Whether we could restore from db, if false, we're starting from a clean slate
     */
    private boolean restoreFromDb() {
        Connection connection = null;
        Snapshot oldSnapshot = this.snapshot;
        try {
            connection = Database.createConnection(EnumSet.of(READ_WRITE));
            Optional<Snapshot> snapshot = Snapshot.loadLatest(connection);
            if (snapshot.isPresent()) {
                this.snapshot = snapshot.get();
                connection.commit();
                return true;
            } else {
                List<Event> events = Event.loadAll(connection);
                Snapshot snapshotBefore = this.snapshot;
                // Instant now = Instant.now(); // TODO: Handle future events later!
                for (Event event : events) {
                    if (!event.isRemoved()/* && event.getTime().isBefore(now)*/) { // TODO: Future events later!
                        this.snapshot = event.apply(this.snapshot);
                        this.snapshot.save(connection);
                    }
                }
                connection.commit();
                return !Objects.equals(this.snapshot, snapshotBefore);
            }
        } catch (SQLException | IOException e) {
            LOG.error("An error occurred when restoring from database!", e);
            this.snapshot = oldSnapshot;
            try {
                if (connection != null) {
                    connection.rollback();
                }
            } catch (SQLException e2) {
                LOG.error("Error in rollback after previous error", e2);
            }
            return false;
        } finally {
            if (connection != null) {
                try {
                    connection.close();
                } catch (SQLException e) {
                    LOG.error("Error while closing connection", e);
                }
            }
        }
    }

    private void registerInitialTags() {
        Snapshot oldSnapshot = this.snapshot;
        Connection connection = null;
        try {
            connection = Database.createConnection(EnumSet.of(READ_WRITE));
            for (Team team : Config.getCurrentConfig().getTeams()) {
                for (TagId tag : team.getTags()) {
                    AddTagEvent e = new AddTagEvent(Instant.EPOCH, tag, team.getTeamNb());
                    e.save(connection);
                    this.snapshot = e.apply(this.snapshot);
                    this.snapshot.save(connection);
                }
            }
            connection.commit();
        } catch (SQLException e) {
            LOG.error("An error occurred when registering initial tags!", e);
            this.snapshot = oldSnapshot;
            try {
                if (connection != null) {
                    connection.rollback();
                }
            } catch (SQLException e2) {
                LOG.error("Error in rollback after previous error", e2);
            }
        } finally {
            if (connection != null) {
                try {
                    connection.close();
                } catch (SQLException e) {
                    LOG.error("Error while closing connection", e);
                }
            }
        }
    }

    private void initCommandProcessor() {
        commandProcessor.addHandler(new PingHandler());
        commandProcessor.addHandler(
                new EventCommandHandler<>(AddTagCommand.class, AddTagEvent::fromCommand, this::queueEvent));
        commandProcessor.addHandler(
Answer:                 new EventCommandHandler<>(RemoveTagCommand.class, RemoveTagEvent::fromCommand, this::queueEvent));