All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.pkts.streams.impl.SimpleCallStateMachine Maven / Gradle / Ivy

There is a newer version: 3.0.10
Show newest version
/**
 * 
 */
package io.pkts.streams.impl;

import static io.pkts.streams.SipStream.CallState.CANCELLED;
import static io.pkts.streams.SipStream.CallState.COMPLETED;
import static io.pkts.streams.SipStream.CallState.FAILED;
import static io.pkts.streams.SipStream.CallState.REDIRECT;
import static io.pkts.streams.SipStream.CallState.REJECTED;
import io.pkts.packet.sip.SipPacket;
import io.pkts.packet.sip.SipPacketParseException;
import io.pkts.packet.sip.SipRequestPacket;
import io.pkts.packet.sip.SipResponsePacket;
import io.pkts.streams.SipStream.CallState;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.NavigableSet;
import java.util.TreeSet;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A "state machine" for SIP but really mainly for SIP VoIP calls. The reason
 * for the quotes around state machine is that it is really not a true state
 * machine for various reasons, one of which is that it allows events to be
 * re-ordered since if multiple pcaps are merged you may actually push a pcap
 * that was captured later first and as such we need to be able to handle this
 * use case.
 * 
 * Also, this state machine is simplified and will for some use cases report the
 * wrong state. However, the purpose of this state machine is (currently anyway)
 * not to be 100% accurate but close enough to be useful and fast.
 * 
 * @author [email protected]
 */
public final class SimpleCallStateMachine {

    private static final Logger logger = LoggerFactory.getLogger(SimpleCallStateMachine.class);

    /**
     * 
     */
    private NavigableSet messages;

    /**
     * A list of all our transitions.
     */
    private List callTransitions;

    /**
     * Should always be the same as the last element in the
     * {@link #callTransitions} list but just for easy access...
     */
    private CallState currentState;

    private final String callId;

    /**
     * The 18x ringing response, if we received one. Only the first will be
     * recorded.
     */
    private SipResponsePacket ringingResponse;

    /**
     * If this call is successfully established, this will be the the first 2xx
     * that we received.
     */
    private SipResponsePacket successResponse;

    /**
     * The first BYE request we received (if any)
     */
    private SipRequestPacket byeRequest;

    /**
     * flag telling us whether we received the ACK on the final response to the
     * INVITE.
     */
    private boolean handshakeIsComplete;

    /**
     * If we detect any kind of re-transmissions we will set this flag.
     */
    private boolean reTransmisionsDetected = false;

    public SimpleCallStateMachine(final String callId) {
        this.callId = callId;
        init();
    }

    /**
     * Start over from a clean slate...
     */
    private void init() {
        this.currentState = CallState.START;
        this.callTransitions = new ArrayList();
        this.messages = new TreeSet(new PacketComparator());
    }

    /**
     * The time of the very first message.
     * 
     * @return the arrival time of the first message seen so far or -1 (negative
     *         one) in case we haven't seen any messages yet.
     */
    public long getTimeOfFirstMessage() {
        if (this.messages.isEmpty()) {
            return -1;
        }

        return this.messages.first().getArrivalTime();
    }

    /**
     * Get the time of the very last message that we have seen so far.
     * 
     * @return the arrival time of the last message seen so far or -1 (negative
     *         one) in case we haven't seen any messages yet.
     */
    public long getTimeOfLastMessage() {
        if (this.messages.isEmpty()) {
            return -1;
        }

        return this.messages.last().getArrivalTime();
    }

    public boolean isHandshakeCompleted() {
        return this.handshakeIsComplete;
    }

    public boolean reTransmitsDetected() {
        return this.reTransmisionsDetected;
    }

    /**
     * At some point we may want to have a generic event but for now this will
     * only be {@link SipPacket}s.
     * 
     * Note, if the {@link SipPacket} arrived earlier than the oldest element
     * that this {@link SimpleCallStateMachine} has seen before then this new
     * {@link SipPacket} will be inserted in the time sequence and then all the
     * events will be "re-played".
     * 
     * @param msg
     */
    public void onEvent(final SipPacket msg) throws SipPacketParseException {
        if (msg == null) {
            return;
        }

        final SipPacket previousMsg = this.messages.isEmpty() ? null : this.messages.last();
        this.messages.add(msg);

        if (previousMsg != null && msg.getArrivalTime() < previousMsg.getArrivalTime()) {
            redrive();
            return;
        }

        try {
            handleStateChange(msg);
        } catch (final SipPacketParseException e) {
            e.printStackTrace();
        }
    }

    private void handleStateChange(final SipPacket msg) throws SipPacketParseException {
        switch (this.currentState) {
            case START:
                initializeState(msg);
                break;
            case INITIAL:
            case TRYING:
            case RINGING:
                handleInProvisionalState(msg);
                break;
            case IN_CALL:
                handleInConfirmedState(msg);
                break;
            case COMPLETED:
                handleInCompletedState(msg);
                break;
            case REDIRECT:
            case REJECTED:
            case FAILED:
                handleInErrorState(msg);
                break;
            case CANCELLING:
                handleInCancellingState(msg);
                break;
            case CANCELLED:
                handleInCancelledState(msg);
                break;
            case UNKNOWN:
                break;
            default:
                throw new RuntimeException("Unknown state, should be impossible. State is: " + this.currentState);
        }
    }

    private void handleInCancelledState(final SipPacket msg) throws SipPacketParseException {
        if (msg.isRequest() && msg.isAck()) {
            this.handshakeIsComplete = true;
        }
        transition(this.currentState, msg);
    }

    /**
     * When in the cancelling state, we may actually end up going back to
     * IN_CALL in case we see a 2xx to the invite so pay attention for that.
     * 
     * @param msg
     * @throws SipPacketParseException
     */
    private void handleInCancellingState(final SipPacket msg) throws SipPacketParseException {

        // we don't move over to cancelled state even if
        // we receive a 200 OK to the cancel request.
        // therefore, not even checking it...
        if (msg.isCancel()) {
            transition(CallState.CANCELLING, msg);
            return;
        }

        if (msg.isRequest()) {

        } else {
            final SipResponsePacket response = msg.toResponse();
            if (response.isInvite()) {
                if (response.getStatus() == 487) {
                    transition(CallState.CANCELLED, msg);
                } else if (response.isSuccess()) {
                    // the cancel didn't make it over in time
                    // so we never cancelled, hence we move
                    // to in call
                    transition(CallState.IN_CALL, msg);
                }
            }

        }

    }

    private void handleInErrorState(final SipPacket msg) throws SipPacketParseException {
        // we could be validating here I guess but for now let's
        // just fall through. Note, we are in a terminal state
        // so we'll just pass in the current state again since we
        // want to stay in this state.
        if (msg.isRequest() && msg.isAck()) {
            this.handshakeIsComplete = true;
        }
        transition(this.currentState, msg);
    }

    /**
     * Handle state transitions for when we are already in the completed state.
     * 
     * @param msg
     * @throws SipPacketParseException
     */
    private void handleInCompletedState(final SipPacket msg) throws SipPacketParseException {
        if (msg.isRequest()) {
            // TODO:
        } else {
            if (msg.isBye()) {
                transition(CallState.COMPLETED, msg);
            }
        }
    }

    /**
     * We will only get to the confirmed state on a 2xx response to the INVITE.
     * From here, we can stay in the confirmed state if we get an ACK or
     * transition over to completed if we get a BYE request.
     * 
     * @param msg
     * @throws SipPacketParseException
     */
    private void handleInConfirmedState(final SipPacket msg) throws SipPacketParseException {
        if (msg.isRequest()) {
            if (msg.isBye()) {
                if (this.byeRequest == null) {
                    this.byeRequest = msg.toRequest();
                }
                transition(CallState.COMPLETED, msg);
            } else if (msg.isAck()) {
                this.handshakeIsComplete = true;
                transition(CallState.IN_CALL, msg);
            }
        } else {
            final SipResponsePacket response = (SipResponsePacket) msg;
            if (response.isSuccess()) {
                // probably re-transmits.
                // need to check it better
                this.reTransmisionsDetected = true;
            } else if (response.isBye()) {
                // already in completed (or should be)
            }
        }
    }

    /**
     * From the initial state we accept the following state changes:
     * 
     * TODO: check what wireshark does for 403, 404, 400 etc etc.
     * 
    *
  • 100 -> TRYING
  • *
  • 180 -> RINGING
  • *
  • 183 -> RINGING
  • *
  • 2xx -> IN_CALL
  • *
  • 3xx -> REDIRECT
  • *
  • A bunch of 4xx responses -> REJECTED
  • *
  • If not rejected 4xx response, then -> FAILED
  • *
  • 5xx -> FAILED
  • *
  • 6xx -> FAILED
  • *
  • *
  • *
  • *
  • *
* * @param msg */ private void handleInProvisionalState(final SipPacket msg) throws SipPacketParseException { if (msg.isRequest() && msg.isCancel()) { transition(CallState.CANCELLING, msg); return; } else if (msg.isRequest()) { // assuming this is either a re-transmission or // a proxy case where the same request is captured // multiple times so therefore just stay in the same // state transition(this.currentState, msg); return; } final boolean isInvite = msg.isInvite(); final SipResponsePacket response = (SipResponsePacket) msg; if (response.is100Trying()) { transition(CallState.TRYING, msg); } else if (response.isRinging()) { if (this.ringingResponse == null) { this.ringingResponse = msg.toResponse(); } transition(CallState.RINGING, msg); } else if (response.isSuccess() && isInvite) { if (this.successResponse == null) { this.successResponse = msg.toResponse(); } transition(CallState.IN_CALL, msg); } else if (response.isRedirect()) { transition(CallState.REDIRECT, msg); } else if (isRejected(response.getStatus())) { transition(CallState.REJECTED, msg); } else if (response.isClientError()) { transition(CallState.FAILED, msg); } else if (response.isServerError()) { transition(CallState.FAILED, msg); } else if (response.isGlobalError()) { transition(CallState.FAILED, msg); } } /** * All the response codes that yield a rejected state. * * Perhaps this should be narrowed down a little more? Perhaps only errors * that the user triggered should lead to a rejected state? E.g., 486 is * typical when the user is either busy or when they pressed the * "ignore call" button. However, a 404 or 480 is not quite the same * thing... * * @param status * @return */ private boolean isRejected(final int status) { return status == 401 || status == 403 || status == 404 || status == 407 || status == 480 || status == 486 || status == 603; } /** * Since traffic can come in any order due to the fact that we can merge * multiple pcaps etc we may miss the initial traffic (perhaps we didn't * even captured it) so when initializing the state we can really jump into * the state machine anywhere. Hence, this method tries to figure out where * in the state machine we need to "jump" into assuming that we would have * received the traffic that has gone missing. * * * @param msg * @throws SipPacketParseException */ private void initializeState(final SipPacket msg) throws SipPacketParseException { if (msg.isRequest()) { if (msg.isInvite() && msg.isInitial()) { transition(CallState.INITIAL, msg); } else if (msg.isAck()) { // TODO: need to figure out whether this is an ACK to a 200 or // a error response since we would transition differently // but not sure how we could if the initial invite is lost transition(CallState.IN_CALL, msg); } else if (msg.isBye() && !msg.isInitial()) { transition(CallState.COMPLETED, msg); } } else { final SipResponsePacket response = (SipResponsePacket) msg; if (response.isInvite()) { if (response.is100Trying()) { transition(CallState.TRYING, msg); } else if (response.isRinging()) { transition(CallState.RINGING, msg); } else if (response.isSuccess()) { transition(CallState.IN_CALL, msg); } } else if (response.isBye() && !response.isInitial()) { transition(CallState.COMPLETED, msg); } } } /** * Helper method for doing transitions. * * @param nextState * @param msg */ private void transition(final CallState nextState, final SipPacket msg) { final CallState previousState = this.currentState; this.currentState = nextState; if (previousState != nextState) { // don't add the same transition twice this.callTransitions.add(nextState); } if (logger.isInfoEnabled()) { logger.info("[{}] {} -> {} Event: {}", this.callId, previousState, this.currentState, msg.getInitialLine()); } } /** * When a messages (or event in general) "arrives" to this state machine but * this message's arrival time is actually before our last seen message then * we will have to re-drive everything. Perhaps we could have been a little * smarter but this just seems easier, safer and less error prone since it * would not require any special logic to figure out what state changes we * would have gone through etc. */ private void redrive() throws SipPacketParseException { if (logger.isInfoEnabled()) { logger.info("Out-of-sequence event detected. Redriving all traffic."); } final NavigableSet oldMessages = this.messages; init(); while (!oldMessages.isEmpty()) { final SipPacket msg = oldMessages.pollFirst(); onEvent(msg); } } /** * Check whether the state of this {@link SimpleCallStateMachine} is * considered to be terminated. * * @return */ public boolean isTerminated() { return this.currentState == COMPLETED || this.currentState == REJECTED || this.currentState == CANCELLED || this.currentState == FAILED || this.currentState == REDIRECT; } /** * Get all the messages that this fsm has seen so far. * * @return */ public List getMessages() { return new ArrayList(this.messages); } /** * Get a list of all the transitions this state machine took. * * @return */ public List getTransitions() { return Collections.unmodifiableList(this.callTransitions); } /** * The current state of this state machine, same as getting the last element * in {@link #getTransitions()} * * @return */ public CallState getCallState() { return this.currentState; } public long getPostDialDelay() throws SipPacketParseException { if (this.messages.isEmpty() || this.ringingResponse == null && this.successResponse == null) { return -1; } final long t1 = this.messages.first().getArrivalTime(); final long t2 = this.ringingResponse != null ? this.ringingResponse.getArrivalTime() : this.successResponse .getArrivalTime(); // if equal, then the first message we received // was a 180 or 183 so we can't calculate the PDD if (t1 == t2) { return -1; } return t2 - t1; } public long getDuration() { if (this.messages.isEmpty() || this.byeRequest == null) { return -1; } final long t1 = this.messages.first().getArrivalTime(); final long t2 = this.byeRequest.getArrivalTime(); if (t1 == t2) { return -1; } return t2 - t1; } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy