
io.pkts.streams.impl.SimpleCallStateMachine Maven / Gradle / Ivy
/**
*
*/
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