39. CD Player

CD Player is a sample which resembles better use case of most of use have used in a real world. CD Player itself is a really simple entity where user can open a deck, insert or change a disk, then drive player functionality by pressing various buttons like eject, play, stop, pause, rewind and backward.

How many of us have really given a thought of what it will take to make a code for a CD Player which interacts with a hardware. Yes, concept of a player is overly simple but if you look behind the scenes things actually get a bit convoluted.

You’ve probably noticed that if your deck is open and you press play, deck will close and a song will start to play if CD was inserted in a first place. In a sense when deck is open you first need to close it and then try to start playing if cd is actually inserted. Hopefully you have now realised that a simple CD Player is not anymore so simple. Sure you can wrap all this with a simple class with few boolean variables and probably few nested if/else clauses, that will do the job, but what about if you need to make all this behaviour much more complex, do you really want to keep adding more flags and if/else clauses.

statechart3

Let’s go through how this sample and its state machine is designed and how those two interacts with each other. Below three config sections are used withing a EnumStateMachineConfigurerAdapter.

@Override
public void configure(StateMachineStateConfigurer<States, Events> states)
        throws Exception {
    states
        .withStates()
            .initial(States.IDLE)
            .state(States.IDLE)
            .and()
            .withStates()
                .parent(States.IDLE)
                .initial(States.CLOSED)
                .state(States.CLOSED, closedEntryAction(), null)
                .state(States.OPEN)
                .and()
        .withStates()
            .state(States.BUSY)
            .and()
            .withStates()
                .parent(States.BUSY)
                .initial(States.PLAYING)
                .state(States.PLAYING)
                .state(States.PAUSED);

}
@Override
public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
        throws Exception {
    transitions
        .withExternal()
            .source(States.CLOSED).target(States.OPEN).event(Events.EJECT)
            .and()
        .withExternal()
            .source(States.OPEN).target(States.CLOSED).event(Events.EJECT)
            .and()
        .withExternal()
            .source(States.OPEN).target(States.CLOSED).event(Events.PLAY)
            .and()
        .withExternal()
            .source(States.PLAYING).target(States.PAUSED).event(Events.PAUSE)
            .and()
        .withInternal()
            .source(States.PLAYING)
            .action(playingAction())
            .timer(1000)
            .and()
        .withInternal()
            .source(States.PLAYING).event(Events.BACK)
            .action(trackAction())
            .and()
        .withInternal()
            .source(States.PLAYING).event(Events.FORWARD)
            .action(trackAction())
            .and()
        .withExternal()
            .source(States.PAUSED).target(States.PLAYING).event(Events.PAUSE)
            .and()
        .withExternal()
            .source(States.BUSY).target(States.IDLE).event(Events.STOP)
            .and()
        .withExternal()
            .source(States.IDLE).target(States.BUSY).event(Events.PLAY)
            .action(playAction())
            .guard(playGuard())
            .and()
        .withInternal()
            .source(States.OPEN).event(Events.LOAD).action(loadAction());
}
@Bean
public ClosedEntryAction closedEntryAction() {
    return new ClosedEntryAction();
}

@Bean
public LoadAction loadAction() {
    return new LoadAction();
}

@Bean
public TrackAction trackAction() {
    return new TrackAction();
}

@Bean
public PlayAction playAction() {
    return new PlayAction();
}

@Bean
public PlayingAction playingAction() {
    return new PlayingAction();
}

@Bean
public PlayGuard playGuard() {
    return new PlayGuard();
}

What we did in above configuration:

This machine only have six states which are introduced as an enum.

public enum States {
    // super state of PLAYING and PAUSED
    BUSY,
    PLAYING,
    PAUSED,
    // super state of CLOSED and OPEN
    IDLE,
    CLOSED,
    OPEN
}

Events represent, in a sense in this example, what buttons user would press and if user loads a cd disc into a deck.

public enum Events {
    PLAY, STOP, PAUSE, EJECT, LOAD, FORWARD, BACK
}

Beans cdPlayer and library are just used with a sample to drive the application.

@Bean
public CdPlayer cdPlayer() {
    return new CdPlayer();
}

@Bean
public Library library() {
    return Library.buildSampleLibrary();
}

We can define extended state variable key as simple enums.

public enum Variables {
    CD, TRACK, ELAPSEDTIME
}

public enum Headers {
    TRACKSHIFT
}

We wanted to make this samply type safe so we’re defining our own annotation @StatesOnTransition which have a mandatory meta annotation @OnTransition.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@OnTransition
public @interface StatesOnTransition {

    States[] source() default {};

    States[] target() default {};

}

ClosedEntryAction is a entry action for state CLOSED to simply send and PLAY event to a statemachine if cd disc is present.

public static class ClosedEntryAction implements Action<States, Events> {

    @Override
    public void execute(StateContext<States, Events> context) {
        if (context.getTransition() != null
                && context.getEvent() == Events.PLAY
                && context.getTransition().getTarget().getId() == States.CLOSED
                && context.getExtendedState().getVariables().get(Variables.CD) != null) {
            context.getStateMachine().sendEvent(Events.PLAY);
        }
    }
}

LoadAction is simply updating extended state variable if event headers contained information about a cd disc to load.

public static class LoadAction implements Action<States, Events> {

    @Override
    public void execute(StateContext<States, Events> context) {
        Object cd = context.getMessageHeader(Variables.CD);
        context.getExtendedState().getVariables().put(Variables.CD, cd);
    }
}

PlayAction is simply resetting player elapsed time which is kept as an extended state variable.

public static class PlayAction implements Action<States, Events> {

    @Override
    public void execute(StateContext<States, Events> context) {
        context.getExtendedState().getVariables().put(Variables.ELAPSEDTIME, 0l);
        context.getExtendedState().getVariables().put(Variables.TRACK, 0);
    }
}

PlayGuard is used to guard transition from IDLE to BUSY with event PLAY if extended state variable CD doesn’t indicate that cd disc has been loaded.

public static class PlayGuard implements Guard<States, Events> {

    @Override
    public boolean evaluate(StateContext<States, Events> context) {
        ExtendedState extendedState = context.getExtendedState();
        return extendedState.getVariables().get(Variables.CD) != null;
    }
}

PlayingAction is updating extended state variable ELAPSEDTIME which cd player itself can read and update lcd status. Action also handles track shift if user is going back or forward in tracks.

public static class PlayingAction implements Action<States, Events> {

    @Override
    public void execute(StateContext<States, Events> context) {
        Map<Object, Object> variables = context.getExtendedState().getVariables();
        Object elapsed = variables.get(Variables.ELAPSEDTIME);
        Object cd = variables.get(Variables.CD);
        Object track = variables.get(Variables.TRACK);
        if (elapsed instanceof Long) {
            long e = ((Long)elapsed) + 1000l;
            if (e > ((Cd) cd).getTracks()[((Integer) track)].getLength()*1000) {
                context.getStateMachine().sendEvent(MessageBuilder
                        .withPayload(Events.FORWARD)
                        .setHeader(Headers.TRACKSHIFT.toString(), 1).build());
            } else {
                variables.put(Variables.ELAPSEDTIME, e);
            }
        }
    }
}

TrackAction handles track shift action if user is going back or forward in tracks. If it is a last track of a cd, playing is stopped and STOP event sent to a state machine.

public static class TrackAction implements Action<States, Events> {

    @Override
    public void execute(StateContext<States, Events> context) {
        Map<Object, Object> variables = context.getExtendedState().getVariables();
        Object trackshift = context.getMessageHeader(Headers.TRACKSHIFT.toString());
        Object track = variables.get(Variables.TRACK);
        Object cd = variables.get(Variables.CD);
        if (trackshift instanceof Integer && track instanceof Integer && cd instanceof Cd) {
            int next = ((Integer)track) + ((Integer)trackshift);
            if (next >= 0 &&  ((Cd)cd).getTracks().length > next) {
                variables.put(Variables.ELAPSEDTIME, 0l);
                variables.put(Variables.TRACK, next);
            } else if (((Cd)cd).getTracks().length <= next) {
                context.getStateMachine().sendEvent(Events.STOP);
            }
        }
    }
}

One other important aspect of a state machines is that they have their own responsibilities mostly around handling states and all application level logic should be kept outside. This means that application needs to have a ways to interact with a state machine and below sample is how cdplayer does it order to update lcd status. Also pay attention that we annotated CdPlayer with @WithStateMachine which instructs state machine to find methods from your pojo which are then called with various transitions.

@OnTransition(target = "BUSY")
public void busy(ExtendedState extendedState) {
    Object cd = extendedState.getVariables().get(Variables.CD);
    if (cd != null) {
        cdStatus = ((Cd)cd).getName();
    }
}

In above example we use @OnTransition annotation to hook a callback when transition happens with a target state BUSY.

@StatesOnTransition(target = {States.CLOSED, States.IDLE})
public void closed(ExtendedState extendedState) {
    Object cd = extendedState.getVariables().get(Variables.CD);
    if (cd != null) {
        cdStatus = ((Cd)cd).getName();
    } else {
        cdStatus = "No CD";
    }
    trackStatus = "";
}

@OnTransition we used above can only be used with strings which are matched from enums. @StatesOnTransition is then something what user can create into his own application to get a type safe annotation where a real enums can be used.

Let’s see an example how this state machine actually works.

sm>sm start
Entry state IDLE
Entry state CLOSED
State machine started

sm>cd lcd
No CD

sm>cd library
0: Greatest Hits
  0: Bohemian Rhapsody  05:56
  1: Another One Bites the Dust  03:36
1: Greatest Hits II
  0: A Kind of Magic  04:22
  1: Under Pressure  04:08

sm>cd eject
Exit state CLOSED
Entry state OPEN

sm>cd load 0
Loading cd Greatest Hits

sm>cd play
Exit state OPEN
Entry state CLOSED
Exit state CLOSED
Exit state IDLE
Entry state BUSY
Entry state PLAYING

sm>cd lcd
Greatest Hits Bohemian Rhapsody 00:03

sm>cd forward

sm>cd lcd
Greatest Hits Another One Bites the Dust 00:04

sm>cd stop
Exit state PLAYING
Exit state BUSY
Entry state IDLE
Entry state CLOSED

sm>cd lcd
Greatest Hits

What happened in above run: