Init commit for standalone enodebd

Change-Id: I88eeef5135dd7ba8551ddd9fb6a0695f5325337b
diff --git a/state_machines/__init__.py b/state_machines/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/state_machines/__init__.py
diff --git a/state_machines/acs_state_utils.py b/state_machines/acs_state_utils.py
new file mode 100644
index 0000000..e0e32cc
--- /dev/null
+++ b/state_machines/acs_state_utils.py
@@ -0,0 +1,328 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+from typing import Any, Dict, List, Optional
+
+from data_models.data_model import DataModel
+from data_models.data_model_parameters import ParameterName
+from device_config.enodeb_configuration import EnodebConfiguration
+from devices.device_utils import EnodebDeviceName, get_device_name
+from exceptions import ConfigurationError
+from logger import EnodebdLogger as logger
+from tr069 import models
+
+
+def process_inform_message(
+    inform: Any,
+    data_model: DataModel,
+    device_cfg: EnodebConfiguration,
+) -> None:
+    """
+    Modifies the device configuration based on what is received in the Inform
+    message. Will raise an error if it turns out that the data model we are
+    using is incorrect. This is decided based on the device OUI and
+    software-version that is reported in the Inform message.
+
+    Args:
+        inform: Inform Tr069 message
+        device_handler: The state machine we are using for our device
+    """
+    param_values_by_path = _get_param_values_by_path(inform)
+    param_name_list = data_model.get_parameter_names()
+    name_to_val = {}
+    for name in param_name_list:
+        path = data_model.get_parameter(name).path
+        if path in param_values_by_path:
+            value = param_values_by_path[path]
+            name_to_val[name] = value
+
+    for name, val in name_to_val.items():
+        device_cfg.set_parameter(name, val)
+
+
+def get_device_name_from_inform(
+    inform: models.Inform,
+) -> EnodebDeviceName:
+    def _get_param_value_from_path_suffix(
+        suffix: str,
+        path_list: List[str],
+        param_values_by_path: Dict[str, Any],
+    ) -> Any:
+        for path in path_list:
+            if path.endswith(suffix):
+                return param_values_by_path[path]
+        raise ConfigurationError('Did not receive expected info in Inform')
+
+    param_values_by_path = _get_param_values_by_path(inform)
+
+    # Check the OUI and version number to see if the data model matches
+    path_list = list(param_values_by_path.keys())
+    if hasattr(inform, 'DeviceId') and \
+            hasattr(inform.DeviceId, 'OUI'):
+        device_oui = inform.DeviceId.OUI
+    else:
+        device_oui = _get_param_value_from_path_suffix(
+            'DeviceInfo.ManufacturerOUI',
+            path_list,
+            param_values_by_path,
+        )
+    sw_version = _get_param_value_from_path_suffix(
+        'DeviceInfo.SoftwareVersion',
+        path_list,
+        param_values_by_path,
+    )
+    return get_device_name(device_oui, sw_version)
+
+
+def does_inform_have_event(
+    inform: models.Inform,
+    event_code: str,
+) -> bool:
+    """ True if the Inform message contains the specified event code """
+    for event in inform.Event.EventStruct:
+        if event.EventCode == event_code:
+            return True
+    return False
+
+
+def _get_param_values_by_path(
+    inform: models.Inform,
+) -> Dict[str, Any]:
+    if not hasattr(inform, 'ParameterList') or \
+            not hasattr(inform.ParameterList, 'ParameterValueStruct'):
+        raise ConfigurationError('Did not receive ParamterList in Inform')
+    param_values_by_path = {}
+    for param_value in inform.ParameterList.ParameterValueStruct:
+        path = param_value.Name
+        value = param_value.Value.Data
+        logger.debug(
+            '(Inform msg) Received parameter: %s = %s', path,
+            value,
+        )
+        param_values_by_path[path] = value
+    return param_values_by_path
+
+
+def are_tr069_params_equal(param_a: Any, param_b: Any, type_: str) -> bool:
+    """
+    Compare two parameters in TR-069 format.
+    The following differences are ignored:
+    - Leading and trailing whitespace, commas and quotes
+    - Capitalization, for booleans (true, false)
+    Returns:
+        True if params are the same
+    """
+    # Cast booleans to integers
+    cmp_a, cmp_b = param_a, param_b
+    if type_ == 'boolean' and cmp_b in ('0', '1') or cmp_a in ('0', '1'):
+        cmp_a, cmp_b = map(int, (cmp_a, cmp_b))
+    cmp_a, cmp_b = map(str, (cmp_a, cmp_b))
+    cmp_a, cmp_b = map(lambda s: s.strip(', \'"'), (cmp_a, cmp_b))
+    if cmp_a.lower() in ['true', 'false']:
+        cmp_a, cmp_b = map(lambda s: s.lower(), (cmp_a, cmp_b))
+    return cmp_a == cmp_b
+
+
+def get_all_objects_to_add(
+    desired_cfg: EnodebConfiguration,
+    device_cfg: EnodebConfiguration,
+) -> List[ParameterName]:
+    """
+    Find a ParameterName that needs to be added to the eNB configuration,
+    if any
+
+    Note: This is the expected name of the parameter once it is added
+          but this is different than how to add it. For example,
+          enumerated objects of the form XX.YY.N. should be added
+          by calling AddObject to XX.YY. and having the CPE assign
+          the index.
+    """
+    desired = desired_cfg.get_object_names()
+    current = device_cfg.get_object_names()
+    return list(set(desired).difference(set(current)))
+
+
+def get_all_objects_to_delete(
+    desired_cfg: EnodebConfiguration,
+    device_cfg: EnodebConfiguration,
+) -> List[ParameterName]:
+    """
+    Find a ParameterName that needs to be deleted from the eNB configuration,
+    if any
+    """
+    desired = desired_cfg.get_object_names()
+    current = device_cfg.get_object_names()
+    return list(set(current).difference(set(desired)))
+
+
+def get_params_to_get(
+    device_cfg: EnodebConfiguration,
+    data_model: DataModel,
+    request_all_params: bool = False,
+) -> List[ParameterName]:
+    """
+    Returns the names of params not belonging to objects that are added/removed
+    """
+    desired_names = data_model.get_present_params()
+    if request_all_params:
+        return desired_names
+    known_names = device_cfg.get_parameter_names()
+    names = list(set(desired_names) - set(known_names))
+    return names
+
+
+def get_object_params_to_get(
+    desired_cfg: Optional[EnodebConfiguration],
+    device_cfg: EnodebConfiguration,
+    data_model: DataModel,
+) -> List[ParameterName]:
+    """
+    Returns a list of parameter names for object parameters we don't know the
+    current value of
+    """
+    names = []
+    # TODO: This might a string for some strange reason, investigate why
+    num_plmns = \
+        int(device_cfg.get_parameter(ParameterName.NUM_PLMNS))
+    for i in range(1, num_plmns + 1):
+        obj_name = ParameterName.PLMN_N % i
+        if not device_cfg.has_object(obj_name):
+            device_cfg.add_object(obj_name)
+        obj_to_params = data_model.get_numbered_param_names()
+        desired = obj_to_params[obj_name]
+        current = []
+        if desired_cfg is not None:
+            current = desired_cfg.get_parameter_names_for_object(obj_name)
+        names_to_add = list(set(desired) - set(current))
+        names = names + names_to_add
+    return names
+
+
+# We don't attempt to set these parameters on the eNB configuration
+READ_ONLY_PARAMETERS = [
+    ParameterName.OP_STATE,
+    ParameterName.RF_TX_STATUS,
+    ParameterName.GPS_STATUS,
+    ParameterName.PTP_STATUS,
+    ParameterName.MME_STATUS,
+    ParameterName.GPS_LAT,
+    ParameterName.GPS_LONG,
+]
+
+
+def get_param_values_to_set(
+    desired_cfg: EnodebConfiguration,
+    device_cfg: EnodebConfiguration,
+    data_model: DataModel,
+    exclude_admin: bool = False,
+) -> Dict[ParameterName, Any]:
+    """
+    Get a map of param names to values for parameters that we will
+    set on the eNB's configuration, excluding parameters for objects that can
+    be added/removed.
+
+    Also exclude special parameters like admin state, since it may be set at
+    a different time in the provisioning process than most parameters.
+    """
+    param_values = {}
+    # Get the parameters we might set
+    params = set(desired_cfg.get_parameter_names()) - set(READ_ONLY_PARAMETERS)
+    if exclude_admin:
+        params = set(params) - {ParameterName.ADMIN_STATE}
+    # Values of parameters
+    for name in params:
+        new = desired_cfg.get_parameter(name)
+        old = device_cfg.get_parameter(name)
+        _type = data_model.get_parameter(name).type
+        if not are_tr069_params_equal(new, old, _type):
+            param_values[name] = new
+
+    return param_values
+
+
+def get_obj_param_values_to_set(
+    desired_cfg: EnodebConfiguration,
+    device_cfg: EnodebConfiguration,
+    data_model: DataModel,
+) -> Dict[ParameterName, Dict[ParameterName, Any]]:
+    """ Returns a map from object name to (a map of param name to value) """
+    param_values = {}
+    objs = desired_cfg.get_object_names()
+    for obj_name in objs:
+        param_values[obj_name] = {}
+        params = desired_cfg.get_parameter_names_for_object(obj_name)
+        for name in params:
+            new = desired_cfg.get_parameter_for_object(name, obj_name)
+            old = device_cfg.get_parameter_for_object(name, obj_name)
+            _type = data_model.get_parameter(name).type
+            if not are_tr069_params_equal(new, old, _type):
+                param_values[obj_name][name] = new
+    return param_values
+
+
+def get_all_param_values_to_set(
+    desired_cfg: EnodebConfiguration,
+    device_cfg: EnodebConfiguration,
+    data_model: DataModel,
+    exclude_admin: bool = False,
+) -> Dict[ParameterName, Any]:
+    """ Returns a map of param names to values that we need to set """
+    param_values = get_param_values_to_set(
+        desired_cfg, device_cfg,
+        data_model, exclude_admin,
+    )
+    obj_param_values = get_obj_param_values_to_set(
+        desired_cfg, device_cfg,
+        data_model,
+    )
+    for _obj_name, param_map in obj_param_values.items():
+        for name, val in param_map.items():
+            param_values[name] = val
+    return param_values
+
+
+def parse_get_parameter_values_response(
+    data_model: DataModel,
+    message: models.GetParameterValuesResponse,
+) -> Dict[ParameterName, Any]:
+    """ Returns a map of ParameterName to the value read from the response """
+    param_values_by_path = {}
+    for param_value_struct in message.ParameterList.ParameterValueStruct:
+        param_values_by_path[param_value_struct.Name] = \
+            param_value_struct.Value.Data
+
+    param_name_list = data_model.get_parameter_names()
+    name_to_val = {}
+    for name in param_name_list:
+        path = data_model.get_parameter(name).path
+        if path in param_values_by_path:
+            value = param_values_by_path[path]
+            name_to_val[name] = value
+
+    return name_to_val
+
+
+def get_optional_param_to_check(
+    data_model: DataModel,
+) -> Optional[ParameterName]:
+    """
+    If there is a parameter which is optional in the data model, and we do not
+    know if it exists or not, then return it so we can check for its presence.
+    """
+    params = data_model.get_names_of_optional_params()
+    for param in params:
+        try:
+            data_model.is_parameter_present(param)
+        except KeyError:
+            return param
+    return None
diff --git a/state_machines/enb_acs.py b/state_machines/enb_acs.py
new file mode 100644
index 0000000..24e24e7
--- /dev/null
+++ b/state_machines/enb_acs.py
@@ -0,0 +1,220 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+from abc import ABC, abstractmethod
+from asyncio import BaseEventLoop
+from time import time
+from typing import Any, Type
+
+from common.service import MagmaService
+from data_models.data_model import DataModel
+from data_models.data_model_parameters import ParameterName
+from device_config.enodeb_config_postprocessor import (
+    EnodebConfigurationPostProcessor,
+)
+from device_config.enodeb_configuration import EnodebConfiguration
+from devices.device_utils import EnodebDeviceName
+from state_machines.acs_state_utils import are_tr069_params_equal
+
+
+class EnodebAcsStateMachine(ABC):
+    """
+    Handles all TR-069 messages.
+    Acts as the Auto Configuration Server (ACS), as specified by TR-069.
+    A device/version specific ACS message handler.
+    Different devices have various idiosyncrasies.
+    Subclass BasicEnodebAcsStateMachine for a specific device/version
+    implementation.
+
+    This ACS class can only handle a single connected eNodeB device.
+    Multiple connected eNodeB devices will lead to undefined behavior.
+
+    This ABC is more of an interface definition.
+    """
+
+    def __init__(self, use_param_key: bool = False) -> None:
+        self._service = None
+        self._desired_cfg = None
+        self._device_cfg = None
+        self._data_model = None
+        self._are_invasive_changes_applied = True
+        # Flag to preseve backwards compatibility
+        self._use_param_key = use_param_key
+        self._param_version_key = None
+
+    def has_parameter(self, param: ParameterName) -> bool:
+        """
+        Return True if the data model has the parameter
+
+        Raise KeyError if the parameter is optional and we do not know yet
+        if this eNodeB has the parameter
+        """
+        return self.data_model.is_parameter_present(param)
+
+    def get_parameter(self, param: ParameterName) -> Any:
+        """
+        Return the value of the parameter
+        """
+        return self.device_cfg.get_parameter(param)
+
+    def set_parameter_asap(self, param: ParameterName, value: Any) -> None:
+        """
+        Set the parameter to the suggested value ASAP
+        """
+        self.desired_cfg.set_parameter(param, value)
+
+    def is_enodeb_configured(self) -> bool:
+        """
+        True if the desired configuration matches the device configuration
+        """
+        if self.desired_cfg is None:
+            return False
+        if not self.data_model.are_param_presences_known():
+            return False
+        desired = self.desired_cfg.get_parameter_names()
+
+        for name in desired:
+            val1 = self.desired_cfg.get_parameter(name)
+            val2 = self.device_cfg.get_parameter(name)
+            type_ = self.data_model.get_parameter(name).type
+            if not are_tr069_params_equal(val1, val2, type_):
+                return False
+
+        for obj_name in self.desired_cfg.get_object_names():
+            params = self.desired_cfg.get_parameter_names_for_object(obj_name)
+            for name in params:
+                val1 = self.device_cfg.get_parameter_for_object(name, obj_name)
+                val2 = self.desired_cfg.get_parameter_for_object(
+                    name,
+                    obj_name,
+                )
+                type_ = self.data_model.get_parameter(name).type
+                if not are_tr069_params_equal(val1, val2, type_):
+                    return False
+        return True
+
+    @abstractmethod
+    def get_state(self) -> str:
+        """
+        Get info about the state of the ACS
+        """
+        pass
+
+    @abstractmethod
+    def handle_tr069_message(self, message: Any) -> Any:
+        """
+        Given a TR-069 message sent from the hardware, return an
+        appropriate response
+        """
+        pass
+
+    @abstractmethod
+    def transition(self, next_state: str) -> None:
+        pass
+
+    @property
+    def service(self) -> MagmaService:
+        return self._service
+
+    @service.setter
+    def service(self, service: MagmaService) -> None:
+        self._service = service
+
+    @property
+    def event_loop(self) -> BaseEventLoop:
+        return self._service.loop
+
+    @property
+    def mconfig(self) -> Any:
+        return self._service.mconfig
+
+    @property
+    def service_config(self) -> Any:
+        return self._service.config
+
+    @property
+    def desired_cfg(self) -> EnodebConfiguration:
+        return self._desired_cfg
+
+    @desired_cfg.setter
+    def desired_cfg(self, val: EnodebConfiguration) -> None:
+        if self.has_version_key:
+            self.parameter_version_inc()
+        self._desired_cfg = val
+
+    @property
+    def device_cfg(self) -> EnodebConfiguration:
+        return self._device_cfg
+
+    @device_cfg.setter
+    def device_cfg(self, val: EnodebConfiguration) -> None:
+        self._device_cfg = val
+
+    @property
+    def data_model(self) -> DataModel:
+        return self._data_model
+
+    @property
+    def has_version_key(self) -> bool:
+        """ Return if the ACS supports param version key """
+        return self._use_param_key
+
+    @property
+    def parameter_version_key(self) -> int:
+        """ Return the param version key """
+        return self._param_version_key
+
+    def parameter_version_inc(self):
+        """ Set the internal version key to the timestamp """
+        self._param_version_key = time()
+
+    @data_model.setter
+    def data_model(self, data_model) -> None:
+        self._data_model = data_model
+
+    @property
+    def are_invasive_changes_applied(self) -> bool:
+        return self._are_invasive_changes_applied
+
+    @are_invasive_changes_applied.setter
+    def are_invasive_changes_applied(self, is_applied: bool) -> None:
+        self._are_invasive_changes_applied = is_applied
+
+    @property
+    @abstractmethod
+    def data_model_class(self) -> Type[DataModel]:
+        pass
+
+    @property
+    @abstractmethod
+    def device_name(self) -> EnodebDeviceName:
+        pass
+
+    @property
+    @abstractmethod
+    def config_postprocessor(self) -> EnodebConfigurationPostProcessor:
+        pass
+
+    @abstractmethod
+    def reboot_asap(self) -> None:
+        """
+        Send a request to reboot the eNodeB ASAP
+        """
+        pass
+
+    @abstractmethod
+    def is_enodeb_connected(self) -> bool:
+        pass
+
+    @abstractmethod
+    def stop_state_machine(self) -> None:
+        pass
diff --git a/state_machines/enb_acs_impl.py b/state_machines/enb_acs_impl.py
new file mode 100644
index 0000000..c29690e
--- /dev/null
+++ b/state_machines/enb_acs_impl.py
@@ -0,0 +1,299 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+import traceback
+from abc import abstractmethod
+from typing import Any, Dict
+
+from common.service import MagmaService
+import metrics
+from data_models.data_model_parameters import ParameterName
+from device_config.enodeb_configuration import EnodebConfiguration
+from exceptions import ConfigurationError
+from logger import EnodebdLogger as logger
+from state_machines.enb_acs import EnodebAcsStateMachine
+from state_machines.enb_acs_states import EnodebAcsState
+from state_machines.timer import StateMachineTimer
+from tr069 import models
+from tr069.models import Tr069ComplexModel
+
+
+class BasicEnodebAcsStateMachine(EnodebAcsStateMachine):
+    """
+    Most of the EnodebAcsStateMachine classes for each device work about the
+    same way. Differences lie mainly in the data model, desired configuration,
+    and the state transition map.
+
+    This class specifies the shared implementation between them.
+    """
+
+    # eNodeB connection timeout is used to determine whether or not eNodeB is
+    # connected to enodebd based on time of last Inform message. By default,
+    # periodic inform interval is 30secs, so timeout should be larger than
+    # this.
+    # Also set timer longer than reboot time, so that an eNodeB reboot does not
+    # trigger a connection-timeout alarm.
+    ENB_CONNECTION_TIMEOUT = 600  # In seconds
+
+    # If eNodeB is disconnected from MME for an unknown reason for this time,
+    # then reboot it. Set to a long time to ensure this doesn't interfere with
+    # other enodebd configuration processes - it is just a measure of last
+    # resort for an unlikely error case
+    MME_DISCONNECT_ENODEB_REBOOT_TIMER = 15 * 60
+
+    # Check the MME connection status every 15 seconds
+    MME_CHECK_TIMER = 15
+
+    def __init__(
+            self,
+            service: MagmaService,
+            use_param_key: bool,
+    ) -> None:
+        super().__init__(use_param_key=use_param_key)
+        self.state = None
+        self.timeout_handler = None
+        self.mme_timeout_handler = None
+        self.mme_timer = None
+        self._start_state_machine(service)
+
+    def get_state(self) -> str:
+        if self.state is None:
+            logger.warning('ACS State machine is not in any state.')
+            return 'N/A'
+        return self.state.state_description()
+
+    def handle_tr069_message(
+            self,
+            message: Tr069ComplexModel,
+    ) -> Tr069ComplexModel:
+        """
+        Accept the tr069 message from the eNB and produce a reply.
+
+        States may transition after reading a message but BEFORE producing
+        a reply. Most steps in the provisioning process are represented as
+        beginning with enodebd sending a request to the eNB, and waiting for
+        the reply from the eNB.
+        """
+        # TransferComplete messages come at random times, and we ignore them
+        if isinstance(message, models.TransferComplete):
+            return models.TransferCompleteResponse()
+        try:
+            self._read_tr069_msg(message)
+            return self._get_tr069_msg(message)
+        except Exception:  # pylint: disable=broad-except
+            logger.error('Failed to handle tr069 message')
+            logger.error(traceback.format_exc())
+            self._dump_debug_info()
+            self.transition(self.unexpected_fault_state_name)
+            return self._get_tr069_msg(message)
+
+    def transition(self, next_state: str) -> Any:
+        logger.debug('State transition to <%s>', next_state)
+        self.state.exit()
+        self.state = self.state_map[next_state]
+        self.state.enter()
+
+    def stop_state_machine(self) -> None:
+        """ Clean up anything the state machine is tracking or doing """
+        self.state.exit()
+        if self.timeout_handler is not None:
+            self.timeout_handler.cancel()
+            self.timeout_handler = None
+        if self.mme_timeout_handler is not None:
+            self.mme_timeout_handler.cancel()
+            self.mme_timeout_handler = None
+        self._service = None
+        self._desired_cfg = None
+        self._device_cfg = None
+        self._data_model = None
+
+        self.mme_timer = None
+
+    def _start_state_machine(
+            self,
+            service: MagmaService,
+    ):
+        self.service = service
+        self.data_model = self.data_model_class()
+        # The current known device config has few known parameters
+        # The desired configuration depends on what the current configuration
+        # is. This we don't know fully, yet.
+        self.device_cfg = EnodebConfiguration(self.data_model)
+
+        self._init_state_map()
+        self.state = self.state_map[self.disconnected_state_name]
+        self.state.enter()
+        self._reset_timeout()
+        self._periodic_check_mme_connection()
+
+    def _reset_state_machine(
+        self,
+        service: MagmaService,
+    ):
+        self.stop_state_machine()
+        self._start_state_machine(service)
+
+    def _read_tr069_msg(self, message: Any) -> None:
+        """ Process incoming message and maybe transition state """
+        self._reset_timeout()
+        msg_handled, next_state = self.state.read_msg(message)
+        if not msg_handled:
+            self._transition_for_unexpected_msg(message)
+            _msg_handled, next_state = self.state.read_msg(message)
+        if next_state is not None:
+            self.transition(next_state)
+
+    def _get_tr069_msg(self, message: Any) -> Any:
+        """ Get a new message to send, and maybe transition state """
+        msg_and_transition = self.state.get_msg(message)
+        if msg_and_transition.next_state:
+            self.transition(msg_and_transition.next_state)
+        msg = msg_and_transition.msg
+        return msg
+
+    def _transition_for_unexpected_msg(self, message: Any) -> None:
+        """
+        eNB devices may send an Inform message in the middle of a provisioning
+        session. To deal with this, transition to a state that expects an
+        Inform message, but also track the status of the eNB as not having
+        been disconnected.
+        """
+        if isinstance(message, models.Inform):
+            logger.debug(
+                'ACS in (%s) state. Received an Inform message',
+                self.state.state_description(),
+            )
+            self._reset_state_machine(self.service)
+        elif isinstance(message, models.Fault):
+            logger.debug(
+                'ACS in (%s) state. Received a Fault <%s>',
+                self.state.state_description(), message.FaultString,
+            )
+            self.transition(self.unexpected_fault_state_name)
+        else:
+            raise ConfigurationError('Cannot handle unexpected TR069 msg')
+
+    def _reset_timeout(self) -> None:
+        if self.timeout_handler is not None:
+            self.timeout_handler.cancel()
+
+        def timed_out():
+            self.transition(self.disconnected_state_name)
+
+        self.timeout_handler = self.event_loop.call_later(
+            self.ENB_CONNECTION_TIMEOUT,
+            timed_out,
+        )
+
+    def _periodic_check_mme_connection(self) -> None:
+        self._check_mme_connection()
+        self.mme_timeout_handler = self.event_loop.call_later(
+            self.MME_CHECK_TIMER,
+            self._periodic_check_mme_connection,
+        )
+
+    def _check_mme_connection(self) -> None:
+        """
+        Check if eNodeB should be connected to MME but isn't, and maybe reboot.
+
+        If the eNB doesn't report connection to MME within a timeout period,
+        get it to reboot in the hope that it will fix things.
+
+        Usually, enodebd polls the eNodeB for whether it is connected to MME.
+        This method checks the last polled MME connection status, and if
+        eNodeB should be connected to MME but it isn't.
+        """
+        if self.device_cfg.has_parameter(ParameterName.MME_STATUS) and \
+                self.device_cfg.get_parameter(ParameterName.MME_STATUS):
+            is_mme_connected = 1
+        else:
+            is_mme_connected = 0
+
+        # True if we would expect MME to be connected, but it isn't
+        is_mme_unexpectedly_dc = \
+            self.is_enodeb_connected() \
+            and self.is_enodeb_configured() \
+            and self.mconfig.allow_enodeb_transmit \
+            and not is_mme_connected
+
+        if is_mme_unexpectedly_dc:
+            logger.warning(
+                'eNodeB is connected to AGw, is configured, '
+                'and has AdminState enabled for transmit. '
+                'MME connection to eNB is missing.',
+            )
+            if self.mme_timer is None:
+                logger.warning(
+                    'eNodeB will be rebooted if MME connection '
+                    'is not established in: %s seconds.',
+                    self.MME_DISCONNECT_ENODEB_REBOOT_TIMER,
+                )
+                metrics.STAT_ENODEB_REBOOT_TIMER_ACTIVE.set(1)
+                self.mme_timer = \
+                    StateMachineTimer(self.MME_DISCONNECT_ENODEB_REBOOT_TIMER)
+            elif self.mme_timer.is_done():
+                logger.warning(
+                    'eNodeB has not established MME connection '
+                    'within %s seconds - rebooting!',
+                    self.MME_DISCONNECT_ENODEB_REBOOT_TIMER,
+                )
+                metrics.STAT_ENODEB_REBOOTS.labels(cause='MME disconnect').inc()
+                metrics.STAT_ENODEB_REBOOT_TIMER_ACTIVE.set(0)
+                self.mme_timer = None
+                self.reboot_asap()
+            else:
+                # eNB is not connected to MME, but we're still waiting to see
+                # if it will connect within the timeout period.
+                # Take no action for now.
+                pass
+        else:
+            if self.mme_timer is not None:
+                logger.info('eNodeB has established MME connection.')
+                self.mme_timer = None
+            metrics.STAT_ENODEB_REBOOT_TIMER_ACTIVE.set(0)
+
+    def _dump_debug_info(self) -> None:
+        if self.device_cfg is not None:
+            logger.error(
+                'Device configuration: %s',
+                self.device_cfg.get_debug_info(),
+            )
+        else:
+            logger.error('Device configuration: None')
+        if self.desired_cfg is not None:
+            logger.error(
+                'Desired configuration: %s',
+                self.desired_cfg.get_debug_info(),
+            )
+        else:
+            logger.error('Desired configuration: None')
+
+    @abstractmethod
+    def _init_state_map(self) -> None:
+        pass
+
+    @property
+    @abstractmethod
+    def state_map(self) -> Dict[str, EnodebAcsState]:
+        pass
+
+    @property
+    @abstractmethod
+    def disconnected_state_name(self) -> str:
+        pass
+
+    @property
+    @abstractmethod
+    def unexpected_fault_state_name(self) -> str:
+        """ State to handle unexpected Fault messages """
+        pass
diff --git a/state_machines/enb_acs_manager.py b/state_machines/enb_acs_manager.py
new file mode 100644
index 0000000..7ad6b02
--- /dev/null
+++ b/state_machines/enb_acs_manager.py
@@ -0,0 +1,253 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+from typing import Any, List, Optional
+
+from common.service import MagmaService
+from device_config.configuration_util import is_enb_registered
+from devices.device_map import get_device_handler_from_name
+from devices.device_utils import EnodebDeviceName
+from exceptions import UnrecognizedEnodebError
+from logger import EnodebdLogger as logger
+from state_machines.acs_state_utils import (
+    get_device_name_from_inform,
+)
+from state_machines.enb_acs import EnodebAcsStateMachine
+from tr069 import models
+from spyne import ComplexModelBase
+from spyne.server.wsgi import WsgiMethodContext
+
+
+class StateMachineManager:
+    """
+    Delegates tr069 message handling to a dedicated state machine for the
+    device.
+    """
+
+    def __init__(
+        self,
+        service: MagmaService,
+    ):
+        self._ip_serial_mapping = IpToSerialMapping()
+        self._service = service
+        self._state_machine_by_ip = {}
+
+    def handle_tr069_message(
+        self,
+        ctx: WsgiMethodContext,
+        tr069_message: ComplexModelBase,
+    ) -> Any:
+        """ Delegate message handling to the appropriate eNB state machine """
+        client_ip = self._get_client_ip(ctx)
+        if isinstance(tr069_message, models.Inform):
+            try:
+                self._update_device_mapping(client_ip, tr069_message)
+            except UnrecognizedEnodebError as err:
+                logger.warning(
+                    'Received TR-069 Inform message from an '
+                    'unrecognized device. '
+                    'Ending TR-069 session with empty HTTP '
+                    'response. Error: (%s)', err,
+                )
+                return models.DummyInput()
+
+        handler = self._get_handler(client_ip)
+        if handler is None:
+            logger.warning(
+                'Received non-Inform TR-069 message from unknown '
+                'eNB. Ending session with empty HTTP response.',
+            )
+            return models.DummyInput()
+
+        return handler.handle_tr069_message(tr069_message)
+
+    def get_handler_by_ip(self, client_ip: str) -> EnodebAcsStateMachine:
+        return self._state_machine_by_ip[client_ip]
+
+    def get_handler_by_serial(self, enb_serial: str) -> EnodebAcsStateMachine:
+        client_ip = self._ip_serial_mapping.get_ip(enb_serial)
+        return self._state_machine_by_ip[client_ip]
+
+    def get_connected_serial_id_list(self) -> List[str]:
+        return self._ip_serial_mapping.get_serial_list()
+
+    def get_ip_of_serial(self, enb_serial: str) -> str:
+        return self._ip_serial_mapping.get_ip(enb_serial)
+
+    def get_serial_of_ip(self, client_ip: str) -> str:
+        serial = self._ip_serial_mapping.get_serial(client_ip)
+        return serial or 'default'
+
+    def _get_handler(
+        self,
+        client_ip: str,
+    ) -> EnodebAcsStateMachine:
+        return self._state_machine_by_ip[client_ip]
+
+    def _update_device_mapping(
+        self,
+        client_ip: str,
+        inform: models.Inform,
+    ) -> None:
+        """
+        When receiving an Inform message, we can figure out what device we
+        are talking to. We can also see if the IP has changed, and the
+        StateMachineManager must track this so that subsequent tr069
+        messages can be handled correctly.
+        """
+        enb_serial = self._parse_msg_for_serial(inform)
+        if enb_serial is None:
+            raise UnrecognizedEnodebError(
+                'eNB does not have serial number '
+                'under expected param path',
+            )
+        if not is_enb_registered(self._service.mconfig, enb_serial):
+            raise UnrecognizedEnodebError(
+                'eNB not registered to this Access '
+                'Gateway (serial #%s)' % enb_serial,
+            )
+        self._associate_serial_to_ip(client_ip, enb_serial)
+        handler = self._get_handler(client_ip)
+        if handler is None:
+            device_name = get_device_name_from_inform(inform)
+            handler = self._build_handler(device_name)
+            self._state_machine_by_ip[client_ip] = handler
+
+    def _associate_serial_to_ip(
+        self,
+        client_ip: str,
+        enb_serial: str,
+    ) -> None:
+        """
+        If a device/IP combination changes, then the StateMachineManager
+        must detect this, and update its mapping of what serial/IP corresponds
+        to which handler.
+        """
+        if self._ip_serial_mapping.has_ip(client_ip):
+            # Same IP, different eNB connected
+            prev_serial = self._ip_serial_mapping.get_serial(client_ip)
+            if enb_serial != prev_serial:
+                logger.info(
+                    'eNodeB change on IP <%s>, from %s to %s',
+                    client_ip, prev_serial, enb_serial,
+                )
+                self._ip_serial_mapping.set_ip_and_serial(client_ip, enb_serial)
+                self._state_machine_by_ip[client_ip] = None
+        elif self._ip_serial_mapping.has_serial(enb_serial):
+            # Same eNB, different IP
+            prev_ip = self._ip_serial_mapping.get_ip(enb_serial)
+            if client_ip != prev_ip:
+                logger.info(
+                    'eNodeB <%s> changed IP from %s to %s',
+                    enb_serial, prev_ip, client_ip,
+                )
+                self._ip_serial_mapping.set_ip_and_serial(client_ip, enb_serial)
+                handler = self._state_machine_by_ip[prev_ip]
+                self._state_machine_by_ip[client_ip] = handler
+                del self._state_machine_by_ip[prev_ip]
+        else:
+            # TR069 message is coming from a different IP, and a different
+            # serial ID. No need to change mapping
+            handler = None
+            self._ip_serial_mapping.set_ip_and_serial(client_ip, enb_serial)
+            self._state_machine_by_ip[client_ip] = handler
+
+    @staticmethod
+    def _parse_msg_for_serial(tr069_message: models.Inform) -> Optional[str]:
+        """ Return the eNodeB serial ID if it's found in the message """
+        if not isinstance(tr069_message, models.Inform):
+            return
+
+        # Mikrotik Intercell does not return serial in ParameterList
+        if hasattr(tr069_message, 'DeviceId') and \
+                hasattr(tr069_message.DeviceId, 'SerialNumber'):
+            return tr069_message.DeviceId.SerialNumber
+
+        if not hasattr(tr069_message, 'ParameterList') or \
+                not hasattr(tr069_message.ParameterList, 'ParameterValueStruct'):
+            return None
+
+        # Parse the parameters
+        param_values_by_path = {}
+        for param_value in tr069_message.ParameterList.ParameterValueStruct:
+            path = param_value.Name
+            value = param_value.Value.Data
+            param_values_by_path[path] = value
+
+        possible_sn_paths = [
+            'Device.DeviceInfo.SerialNumber',
+            'InternetGatewayDevice.DeviceInfo.SerialNumber',
+        ]
+        for path in possible_sn_paths:
+            if path in param_values_by_path:
+                return param_values_by_path[path]
+        return None
+
+    @staticmethod
+    def _get_client_ip(ctx: WsgiMethodContext) -> str:
+        return ctx.transport.req_env.get("REMOTE_ADDR", "unknown")
+
+    def _build_handler(
+        self,
+        device_name: EnodebDeviceName,
+    ) -> EnodebAcsStateMachine:
+        """
+        Create a new state machine based on the device type
+        """
+        device_handler_class = get_device_handler_from_name(device_name)
+        acs_state_machine = device_handler_class(self._service)
+        return acs_state_machine
+
+
+class IpToSerialMapping:
+    """ Bidirectional map between <eNodeB IP> and <eNodeB serial ID> """
+
+    def __init__(self) -> None:
+        self.ip_by_enb_serial = {}
+        self.enb_serial_by_ip = {}
+
+    def del_ip(self, ip: str) -> None:
+        if ip not in self.enb_serial_by_ip:
+            raise KeyError('Cannot delete missing IP')
+        serial = self.enb_serial_by_ip[ip]
+        del self.enb_serial_by_ip[ip]
+        del self.ip_by_enb_serial[serial]
+
+    def del_serial(self, serial: str) -> None:
+        if serial not in self.ip_by_enb_serial:
+            raise KeyError('Cannot delete missing eNodeB serial ID')
+        ip = self.ip_by_enb_serial[serial]
+        del self.ip_by_enb_serial[serial]
+        del self.enb_serial_by_ip[ip]
+
+    def set_ip_and_serial(self, ip: str, serial: str) -> None:
+        self.ip_by_enb_serial[serial] = ip
+        self.enb_serial_by_ip[ip] = serial
+
+    def get_ip(self, serial: str) -> str:
+        return self.ip_by_enb_serial[serial]
+
+    def get_serial(self, ip: str) -> Optional[str]:
+        return self.enb_serial_by_ip.get(ip, None)
+
+    def has_ip(self, ip: str) -> bool:
+        return ip in self.enb_serial_by_ip
+
+    def has_serial(self, serial: str) -> bool:
+        return serial in self.ip_by_enb_serial
+
+    def get_serial_list(self) -> List[str]:
+        return list(self.ip_by_enb_serial.keys())
+
+    def get_ip_list(self) -> List[str]:
+        return list(self.enb_serial_by_ip.keys())
diff --git a/state_machines/enb_acs_pointer.py b/state_machines/enb_acs_pointer.py
new file mode 100644
index 0000000..3ec19a7
--- /dev/null
+++ b/state_machines/enb_acs_pointer.py
@@ -0,0 +1,38 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+from state_machines.enb_acs import EnodebAcsStateMachine
+
+
+class StateMachinePointer:
+    """
+    This is a hack to deal with the possibility that the specified data model
+    doesn't match the eNB device enodebd ends up connecting to.
+
+    When the data model doesn't match, the state machine is replaced with one
+    that matches the data model.
+    """
+
+    def __init__(self, acs_state_machine: EnodebAcsStateMachine):
+        self._acs_state_machine = acs_state_machine
+
+    @property
+    def state_machine(self):
+        return self._acs_state_machine
+
+    @state_machine.setter
+    def state_machine(
+        self,
+        acs_state_machine: EnodebAcsStateMachine,
+    ) -> None:
+        self._acs_state_machine = acs_state_machine
diff --git a/state_machines/enb_acs_states.py b/state_machines/enb_acs_states.py
new file mode 100644
index 0000000..a9b84a5
--- /dev/null
+++ b/state_machines/enb_acs_states.py
@@ -0,0 +1,1293 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+from abc import ABC, abstractmethod
+from collections import namedtuple
+from typing import Any, Optional
+
+from data_models.data_model import InvalidTrParamPath
+from data_models.data_model_parameters import ParameterName
+from device_config.configuration_init import build_desired_config
+from exceptions import ConfigurationError, Tr069Error
+from logger import EnodebdLogger as logger
+from state_machines.acs_state_utils import (
+    does_inform_have_event,
+    get_all_objects_to_add,
+    get_all_objects_to_delete,
+    get_all_param_values_to_set,
+    get_obj_param_values_to_set,
+    get_object_params_to_get,
+    get_optional_param_to_check,
+    get_param_values_to_set,
+    get_params_to_get,
+    parse_get_parameter_values_response,
+    process_inform_message,
+)
+from state_machines.enb_acs import EnodebAcsStateMachine
+from state_machines.timer import StateMachineTimer
+from tr069 import models
+
+AcsMsgAndTransition = namedtuple(
+    'AcsMsgAndTransition', ['msg', 'next_state'],
+)
+
+AcsReadMsgResult = namedtuple(
+    'AcsReadMsgResult', ['msg_handled', 'next_state'],
+)
+
+
+class EnodebAcsState(ABC):
+    """
+    State class for the Enodeb state machine
+
+    States can transition after reading a message from the eNB, sending a
+    message out to the eNB, or when a timer completes. As such, some states
+    are only responsible for message sending, and others are only responsible
+    for reading incoming messages.
+
+    In the constructor, set up state transitions.
+    """
+
+    def __init__(self):
+        self._acs = None
+
+    def enter(self) -> None:
+        """
+        Set up your timers here. Call transition(..) on the ACS when the timer
+        completes or throw an error
+        """
+        pass
+
+    def exit(self) -> None:
+        """Destroy timers here"""
+        pass
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        """
+        Args: message: tr069 message
+        Returns: name of the next state, if transition required
+        """
+        raise ConfigurationError(
+            '%s should implement read_msg() if it '
+            'needs to handle message reading' % self.__class__.__name__,
+        )
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        """
+        Produce a message to send back to the eNB.
+
+        Args:
+            message: TR-069 message which was already processed by read_msg
+
+        Returns: Message and possible transition
+        """
+        raise ConfigurationError(
+            '%s should implement get_msg() if it '
+            'needs to produce messages' % self.__class__.__name__,
+        )
+
+    @property
+    def acs(self) -> EnodebAcsStateMachine:
+        return self._acs
+
+    @acs.setter
+    def acs(self, val: EnodebAcsStateMachine) -> None:
+        self._acs = val
+
+    @abstractmethod
+    def state_description(self) -> str:
+        """ Provide a few words about what the state represents """
+        pass
+
+
+class WaitInformState(EnodebAcsState):
+    """
+    This state indicates that no Inform message has been received yet, or
+    that no Inform message has been received for a long time.
+
+    This state is used to handle an Inform message that arrived when enodebd
+    already believes that the eNB is connected. As such, it is unclear to
+    enodebd whether the eNB is just sending another Inform, or if a different
+    eNB was plugged into the same interface.
+    """
+
+    def __init__(
+        self,
+        acs: EnodebAcsStateMachine,
+        when_done: str,
+        when_boot: Optional[str] = None,
+    ):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+        self.boot_transition = when_boot
+        self.has_enb_just_booted = False
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        """
+        Args:
+            message: models.Inform Tr069 Inform message
+        """
+        if not isinstance(message, models.Inform):
+            return AcsReadMsgResult(False, None)
+        process_inform_message(
+            message, self.acs.data_model,
+            self.acs.device_cfg,
+        )
+        if does_inform_have_event(message, '1 BOOT'):
+            return AcsReadMsgResult(True, self.boot_transition)
+        return AcsReadMsgResult(True, None)
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        """ Reply with InformResponse """
+        response = models.InformResponse()
+        # Set maxEnvelopes to 1, as per TR-069 spec
+        response.MaxEnvelopes = 1
+        return AcsMsgAndTransition(response, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Waiting for an Inform'
+
+
+class GetRPCMethodsState(EnodebAcsState):
+    """
+    After the first Inform message from boot, it is expected that the eNB
+    will try to learn the RPC methods of the ACS.
+    """
+
+    def __init__(self, acs: EnodebAcsStateMachine, when_done: str, when_skip: str):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+        self.skip_transition = when_skip
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        # If this is a regular Inform, not after a reboot we'll get an empty
+        if isinstance(message, models.DummyInput):
+            return AcsReadMsgResult(True, self.skip_transition)
+        if not isinstance(message, models.GetRPCMethods):
+            return AcsReadMsgResult(False, self.done_transition)
+        return AcsReadMsgResult(True, None)
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        resp = models.GetRPCMethodsResponse()
+        resp.MethodList = models.MethodList()
+        RPC_METHODS = ['Inform', 'GetRPCMethods', 'TransferComplete']
+        resp.MethodList.arrayType = 'xsd:string[%d]' \
+                                          % len(RPC_METHODS)
+        resp.MethodList.string = RPC_METHODS
+        return AcsMsgAndTransition(resp, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Waiting for incoming GetRPC Methods after boot'
+
+
+class BaicellsRemWaitState(EnodebAcsState):
+    """
+    We've already received an Inform message. This state is to handle a
+    Baicells eNodeB issue.
+
+    After eNodeB is rebooted, hold off configuring it for some time to give
+    time for REM to run. This is a BaiCells eNodeB issue that doesn't support
+    enabling the eNodeB during initial REM.
+
+    In this state, just hang at responding to Inform, and then ending the
+    TR-069 session.
+    """
+
+    CONFIG_DELAY_AFTER_BOOT = 600
+
+    def __init__(self, acs: EnodebAcsStateMachine, when_done: str):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+        self.rem_timer = None
+
+    def enter(self):
+        self.rem_timer = StateMachineTimer(self.CONFIG_DELAY_AFTER_BOOT)
+        logger.info(
+            'Holding off of eNB configuration for %s seconds. '
+            'Will resume after eNB REM process has finished. ',
+            self.CONFIG_DELAY_AFTER_BOOT,
+        )
+
+    def exit(self):
+        self.rem_timer = None
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        if not isinstance(message, models.Inform):
+            return AcsReadMsgResult(False, None)
+        process_inform_message(
+            message, self.acs.data_model,
+            self.acs.device_cfg,
+        )
+        return AcsReadMsgResult(True, None)
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        if self.rem_timer.is_done():
+            return AcsMsgAndTransition(
+                models.DummyInput(),
+                self.done_transition,
+            )
+        return AcsMsgAndTransition(models.DummyInput(), None)
+
+    def state_description(self) -> str:
+        remaining = self.rem_timer.seconds_remaining()
+        return 'Waiting for eNB REM to run for %d more seconds before ' \
+               'resuming with configuration.' % remaining
+
+
+class WaitEmptyMessageState(EnodebAcsState):
+    def __init__(
+        self,
+        acs: EnodebAcsStateMachine,
+        when_done: str,
+        when_missing: Optional[str] = None,
+    ):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+        self.unknown_param_transition = when_missing
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        """
+        It's expected that we transition into this state right after receiving
+        an Inform message and replying with an InformResponse. At that point,
+        the eNB sends an empty HTTP request (aka DummyInput) to initiate the
+        rest of the provisioning process
+        """
+        if not isinstance(message, models.DummyInput):
+            logger.debug("Ignoring message %s", str(type(message)))
+            return AcsReadMsgResult(msg_handled=False, next_state=None)
+        if self.unknown_param_transition:
+            if get_optional_param_to_check(self.acs.data_model):
+                return AcsReadMsgResult(
+                    msg_handled=True,
+                    next_state=self.unknown_param_transition,
+                )
+        return AcsReadMsgResult(
+            msg_handled=True,
+            next_state=self.done_transition,
+        )
+
+    def get_msg(self, message: Any) -> AcsReadMsgResult:
+        """
+        Return a dummy message waiting for the empty message from CPE
+        """
+        request = models.DummyInput()
+        return AcsMsgAndTransition(msg=request, next_state=None)
+
+    def state_description(self) -> str:
+        return 'Waiting for empty message from eNodeB'
+
+
+class CheckOptionalParamsState(EnodebAcsState):
+    def __init__(
+            self,
+            acs: EnodebAcsStateMachine,
+            when_done: str,
+    ):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+        self.optional_param = None
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        self.optional_param = get_optional_param_to_check(self.acs.data_model)
+        if self.optional_param is None:
+            raise Tr069Error('Invalid State')
+        # Generate the request
+        request = models.GetParameterValues()
+        request.ParameterNames = models.ParameterNames()
+        request.ParameterNames.arrayType = 'xsd:string[1]'
+        request.ParameterNames.string = []
+        path = self.acs.data_model.get_parameter(self.optional_param).path
+        request.ParameterNames.string.append(path)
+        return AcsMsgAndTransition(request, None)
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        """ Process either GetParameterValuesResponse or a Fault """
+        if type(message) == models.Fault:
+            self.acs.data_model.set_parameter_presence(
+                self.optional_param,
+                False,
+            )
+        elif type(message) == models.GetParameterValuesResponse:
+            name_to_val = parse_get_parameter_values_response(
+                self.acs.data_model,
+                message,
+            )
+            logger.debug(
+                'Received CPE parameter values: %s',
+                str(name_to_val),
+            )
+            for name, val in name_to_val.items():
+                self.acs.data_model.set_parameter_presence(
+                    self.optional_param,
+                    True,
+                )
+                magma_val = self.acs.data_model.transform_for_magma(name, val)
+                self.acs.device_cfg.set_parameter(name, magma_val)
+        else:
+            return AcsReadMsgResult(False, None)
+
+        if get_optional_param_to_check(self.acs.data_model) is not None:
+            return AcsReadMsgResult(True, None)
+        return AcsReadMsgResult(True, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Checking if some optional parameters exist in data model'
+
+
+class SendGetTransientParametersState(EnodebAcsState):
+    """
+    Periodically read eNodeB status. Note: keep frequency low to avoid
+    backing up large numbers of read operations if enodebd is busy.
+    Some eNB parameters are read only and updated by the eNB itself.
+    """
+    PARAMETERS = [
+        ParameterName.OP_STATE,
+        ParameterName.RF_TX_STATUS,
+        ParameterName.GPS_STATUS,
+        ParameterName.PTP_STATUS,
+        ParameterName.MME_STATUS,
+        ParameterName.GPS_LAT,
+        ParameterName.GPS_LONG,
+    ]
+
+    def __init__(self, acs: EnodebAcsStateMachine, when_done: str):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        if not isinstance(message, models.DummyInput):
+            return AcsReadMsgResult(False, None)
+        return AcsReadMsgResult(True, None)
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        request = models.GetParameterValues()
+        request.ParameterNames = models.ParameterNames()
+        request.ParameterNames.string = []
+        for name in self.PARAMETERS:
+            # Not all data models have these parameters
+            if self.acs.data_model.is_parameter_present(name):
+                path = self.acs.data_model.get_parameter(name).path
+                request.ParameterNames.string.append(path)
+        request.ParameterNames.arrayType = \
+            'xsd:string[%d]' % len(request.ParameterNames.string)
+
+        return AcsMsgAndTransition(request, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Getting transient read-only parameters'
+
+
+class WaitGetTransientParametersState(EnodebAcsState):
+    """
+    Periodically read eNodeB status. Note: keep frequency low to avoid
+    backing up large numbers of read operations if enodebd is busy
+    """
+
+    def __init__(
+            self,
+            acs: EnodebAcsStateMachine,
+            when_get: str,
+            when_get_obj_params: str,
+            when_delete: str,
+            when_add: str,
+            when_set: str,
+            when_skip: str,
+    ):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_get
+        self.get_obj_params_transition = when_get_obj_params
+        self.rm_obj_transition = when_delete
+        self.add_obj_transition = when_add
+        self.set_transition = when_set
+        self.skip_transition = when_skip
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        if not isinstance(message, models.GetParameterValuesResponse):
+            return AcsReadMsgResult(False, None)
+        # Current values of the fetched parameters
+        name_to_val = parse_get_parameter_values_response(
+            self.acs.data_model,
+            message,
+        )
+        logger.debug('Fetched Transient Params: %s', str(name_to_val))
+
+        # Update device configuration
+        for name in name_to_val:
+            magma_val = \
+                self.acs.data_model.transform_for_magma(
+                    name,
+                    name_to_val[name],
+                )
+            self.acs.device_cfg.set_parameter(name, magma_val)
+
+        return AcsReadMsgResult(True, self.get_next_state())
+
+    def get_next_state(self) -> str:
+        should_get_params = \
+            len(
+                get_params_to_get(
+                    self.acs.device_cfg,
+                    self.acs.data_model,
+                ),
+            ) > 0
+        if should_get_params:
+            return self.done_transition
+        should_get_obj_params = \
+            len(
+                get_object_params_to_get(
+                    self.acs.desired_cfg,
+                    self.acs.device_cfg,
+                    self.acs.data_model,
+                ),
+            ) > 0
+        if should_get_obj_params:
+            return self.get_obj_params_transition
+        elif len(
+            get_all_objects_to_delete(
+                self.acs.desired_cfg,
+                self.acs.device_cfg,
+            ),
+        ) > 0:
+            return self.rm_obj_transition
+        elif len(
+            get_all_objects_to_add(
+                self.acs.desired_cfg,
+                self.acs.device_cfg,
+            ),
+        ) > 0:
+            return self.add_obj_transition
+        return self.skip_transition
+
+    def state_description(self) -> str:
+        return 'Getting transient read-only parameters'
+
+
+class GetParametersState(EnodebAcsState):
+    """
+    Get the value of most parameters of the eNB that are defined in the data
+    model. Object parameters are excluded.
+    """
+
+    def __init__(
+        self,
+        acs: EnodebAcsStateMachine,
+        when_done: str,
+        request_all_params: bool = False,
+    ):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+        # Set to True if we want to request values of all parameters, even if
+        # the ACS state machine already has recorded values of them.
+        self.request_all_params = request_all_params
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        """
+        It's expected that we transition into this state right after receiving
+        an Inform message and replying with an InformResponse. At that point,
+        the eNB sends an empty HTTP request (aka DummyInput) to initiate the
+        rest of the provisioning process
+        """
+        if not isinstance(message, models.DummyInput):
+            return AcsReadMsgResult(False, None)
+        return AcsReadMsgResult(True, None)
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        """
+        Respond with GetParameterValuesRequest
+
+        Get the values of all parameters defined in the data model.
+        Also check which addable objects are present, and what the values of
+        parameters for those objects are.
+        """
+
+        # Get the names of regular parameters
+        names = get_params_to_get(
+            self.acs.device_cfg, self.acs.data_model,
+            self.request_all_params,
+        )
+
+        # Generate the request
+        request = models.GetParameterValues()
+        request.ParameterNames = models.ParameterNames()
+        request.ParameterNames.arrayType = 'xsd:string[%d]' \
+                                           % len(names)
+        request.ParameterNames.string = []
+        for name in names:
+            path = self.acs.data_model.get_parameter(name).path
+            if path is not InvalidTrParamPath:
+                # Only get data elements backed by tr69 path
+                request.ParameterNames.string.append(path)
+
+        return AcsMsgAndTransition(request, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Getting non-object parameters'
+
+
+class WaitGetParametersState(EnodebAcsState):
+    def __init__(self, acs: EnodebAcsStateMachine, when_done: str):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        """ Process GetParameterValuesResponse """
+        if not isinstance(message, models.GetParameterValuesResponse):
+            return AcsReadMsgResult(False, None)
+        name_to_val = parse_get_parameter_values_response(
+            self.acs.data_model,
+            message,
+        )
+        logger.debug('Received CPE parameter values: %s', str(name_to_val))
+        for name, val in name_to_val.items():
+            magma_val = self.acs.data_model.transform_for_magma(name, val)
+            self.acs.device_cfg.set_parameter(name, magma_val)
+        return AcsReadMsgResult(True, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Getting non-object parameters'
+
+
+class GetObjectParametersState(EnodebAcsState):
+    def __init__(self, acs: EnodebAcsStateMachine, when_done: str):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        """ Respond with GetParameterValuesRequest """
+        names = get_object_params_to_get(
+            self.acs.desired_cfg,
+            self.acs.device_cfg,
+            self.acs.data_model,
+        )
+
+        # Generate the request
+        request = models.GetParameterValues()
+        request.ParameterNames = models.ParameterNames()
+        request.ParameterNames.arrayType = 'xsd:string[%d]' \
+                                           % len(names)
+        request.ParameterNames.string = []
+        for name in names:
+            path = self.acs.data_model.get_parameter(name).path
+            request.ParameterNames.string.append(path)
+
+        return AcsMsgAndTransition(request, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Getting object parameters'
+
+
+class WaitGetObjectParametersState(EnodebAcsState):
+    def __init__(
+        self,
+        acs: EnodebAcsStateMachine,
+        when_delete: str,
+        when_add: str,
+        when_set: str,
+        when_skip: str,
+    ):
+        super().__init__()
+        self.acs = acs
+        self.rm_obj_transition = when_delete
+        self.add_obj_transition = when_add
+        self.set_params_transition = when_set
+        self.skip_transition = when_skip
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        """ Process GetParameterValuesResponse """
+        if not isinstance(message, models.GetParameterValuesResponse):
+            return AcsReadMsgResult(False, None)
+
+        path_to_val = {}
+        if hasattr(message.ParameterList, 'ParameterValueStruct') and \
+                message.ParameterList.ParameterValueStruct is not None:
+            for param_value_struct in message.ParameterList.ParameterValueStruct:
+                path_to_val[param_value_struct.Name] = \
+                    param_value_struct.Value.Data
+        logger.debug('Received object parameters: %s', str(path_to_val))
+
+        # Number of PLMN objects reported can be incorrect. Let's count them
+        num_plmns = 0
+        obj_to_params = self.acs.data_model.get_numbered_param_names()
+        while True:
+            obj_name = ParameterName.PLMN_N % (num_plmns + 1)
+            if obj_name not in obj_to_params or len(obj_to_params[obj_name]) == 0:
+                logger.warning(
+                    "eNB has PLMN %s but not defined in model",
+                    obj_name,
+                )
+                break
+            param_name_list = obj_to_params[obj_name]
+            obj_path = self.acs.data_model.get_parameter(param_name_list[0]).path
+            if obj_path not in path_to_val:
+                break
+            if not self.acs.device_cfg.has_object(obj_name):
+                self.acs.device_cfg.add_object(obj_name)
+            num_plmns += 1
+            for name in param_name_list:
+                path = self.acs.data_model.get_parameter(name).path
+                value = path_to_val[path]
+                magma_val = \
+                    self.acs.data_model.transform_for_magma(name, value)
+                self.acs.device_cfg.set_parameter_for_object(
+                    name, magma_val,
+                    obj_name,
+                )
+        num_plmns_reported = \
+                int(self.acs.device_cfg.get_parameter(ParameterName.NUM_PLMNS))
+        if num_plmns != num_plmns_reported:
+            logger.warning(
+                "eNB reported %d PLMNs but found %d",
+                num_plmns_reported, num_plmns,
+            )
+            self.acs.device_cfg.set_parameter(
+                ParameterName.NUM_PLMNS,
+                num_plmns,
+            )
+
+        # Now we can have the desired state
+        if self.acs.desired_cfg is None:
+            self.acs.desired_cfg = build_desired_config(
+                self.acs.mconfig,
+                self.acs.service_config,
+                self.acs.device_cfg,
+                self.acs.data_model,
+                self.acs.config_postprocessor,
+            )
+
+        if len(
+            get_all_objects_to_delete(
+                self.acs.desired_cfg,
+                self.acs.device_cfg,
+            ),
+        ) > 0:
+            return AcsReadMsgResult(True, self.rm_obj_transition)
+        elif len(
+            get_all_objects_to_add(
+                self.acs.desired_cfg,
+                self.acs.device_cfg,
+            ),
+        ) > 0:
+            return AcsReadMsgResult(True, self.add_obj_transition)
+        elif len(
+            get_all_param_values_to_set(
+                self.acs.desired_cfg,
+                self.acs.device_cfg,
+                self.acs.data_model,
+            ),
+        ) > 0:
+            return AcsReadMsgResult(True, self.set_params_transition)
+        return AcsReadMsgResult(True, self.skip_transition)
+
+    def state_description(self) -> str:
+        return 'Getting object parameters'
+
+
+class DeleteObjectsState(EnodebAcsState):
+    def __init__(
+        self,
+        acs: EnodebAcsStateMachine,
+        when_add: str,
+        when_skip: str,
+    ):
+        super().__init__()
+        self.acs = acs
+        self.deleted_param = None
+        self.add_obj_transition = when_add
+        self.skip_transition = when_skip
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        """
+        Send DeleteObject message to TR-069 and poll for response(s).
+        Input:
+            - Object name (string)
+        """
+        request = models.DeleteObject()
+        self.deleted_param = get_all_objects_to_delete(
+            self.acs.desired_cfg,
+            self.acs.device_cfg,
+        )[0]
+        request.ObjectName = \
+            self.acs.data_model.get_parameter(self.deleted_param).path
+        return AcsMsgAndTransition(request, None)
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        """
+        Send DeleteObject message to TR-069 and poll for response(s).
+        Input:
+            - Object name (string)
+        """
+        if type(message) == models.DeleteObjectResponse:
+            if message.Status != 0:
+                raise Tr069Error(
+                    'Received DeleteObjectResponse with '
+                    'Status=%d' % message.Status,
+                )
+        elif type(message) == models.Fault:
+            raise Tr069Error(
+                'Received Fault in response to DeleteObject '
+                '(faultstring = %s)' % message.FaultString,
+            )
+        else:
+            return AcsReadMsgResult(False, None)
+
+        self.acs.device_cfg.delete_object(self.deleted_param)
+        obj_list_to_delete = get_all_objects_to_delete(
+            self.acs.desired_cfg,
+            self.acs.device_cfg,
+        )
+        if len(obj_list_to_delete) > 0:
+            return AcsReadMsgResult(True, None)
+        if len(
+            get_all_objects_to_add(
+                self.acs.desired_cfg,
+                self.acs.device_cfg,
+            ),
+        ) == 0:
+            return AcsReadMsgResult(True, self.skip_transition)
+        return AcsReadMsgResult(True, self.add_obj_transition)
+
+    def state_description(self) -> str:
+        return 'Deleting objects'
+
+
+class AddObjectsState(EnodebAcsState):
+    def __init__(self, acs: EnodebAcsStateMachine, when_done: str):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+        self.added_param = None
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        request = models.AddObject()
+        self.added_param = get_all_objects_to_add(
+            self.acs.desired_cfg,
+            self.acs.device_cfg,
+        )[0]
+        desired_param = self.acs.data_model.get_parameter(self.added_param)
+        desired_path = desired_param.path
+        path_parts = desired_path.split('.')
+        # If adding enumerated object, ie. XX.N. we should add it to the
+        # parent object XX. so strip the index
+        if len(path_parts) > 2 and \
+                path_parts[-1] == '' and path_parts[-2].isnumeric():
+            logger.debug('Stripping index from path=%s', desired_path)
+            desired_path = '.'.join(path_parts[:-2]) + '.'
+        request.ObjectName = desired_path
+        return AcsMsgAndTransition(request, None)
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        if type(message) == models.AddObjectResponse:
+            if message.Status != 0:
+                raise Tr069Error(
+                    'Received AddObjectResponse with '
+                    'Status=%d' % message.Status,
+                )
+        elif type(message) == models.Fault:
+            raise Tr069Error(
+                'Received Fault in response to AddObject '
+                '(faultstring = %s)' % message.FaultString,
+            )
+        else:
+            return AcsReadMsgResult(False, None)
+        instance_n = message.InstanceNumber
+        self.acs.device_cfg.add_object(self.added_param % instance_n)
+        obj_list_to_add = get_all_objects_to_add(
+            self.acs.desired_cfg,
+            self.acs.device_cfg,
+        )
+        if len(obj_list_to_add) > 0:
+            return AcsReadMsgResult(True, None)
+        return AcsReadMsgResult(True, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Adding objects'
+
+
+class SetParameterValuesState(EnodebAcsState):
+    def __init__(self, acs: EnodebAcsStateMachine, when_done: str):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        request = models.SetParameterValues()
+        request.ParameterList = models.ParameterValueList()
+        param_values = get_all_param_values_to_set(
+            self.acs.desired_cfg,
+            self.acs.device_cfg,
+            self.acs.data_model,
+        )
+        request.ParameterList.arrayType = 'cwmp:ParameterValueStruct[%d]' \
+                                           % len(param_values)
+        request.ParameterList.ParameterValueStruct = []
+        logger.debug(
+            'Sending TR069 request to set CPE parameter values: %s',
+            str(param_values),
+        )
+        # TODO: Match key response when we support having multiple outstanding
+        # calls.
+        if self.acs.has_version_key:
+            request.ParameterKey = models.ParameterKeyType()
+            request.ParameterKey.Data =\
+                "SetParameter-{:10.0f}".format(self.acs.parameter_version_key)
+            request.ParameterKey.type = 'xsd:string'
+
+        for name, value in param_values.items():
+            param_info = self.acs.data_model.get_parameter(name)
+            type_ = param_info.type
+            name_value = models.ParameterValueStruct()
+            name_value.Value = models.anySimpleType()
+            name_value.Name = param_info.path
+            enb_value = self.acs.data_model.transform_for_enb(name, value)
+            if type_ in ('int', 'unsignedInt'):
+                name_value.Value.type = 'xsd:%s' % type_
+                name_value.Value.Data = str(enb_value)
+            elif type_ == 'boolean':
+                # Boolean values have integral representations in spec
+                name_value.Value.type = 'xsd:boolean'
+                name_value.Value.Data = str(int(enb_value))
+            elif type_ == 'string':
+                name_value.Value.type = 'xsd:string'
+                name_value.Value.Data = str(enb_value)
+            else:
+                raise Tr069Error(
+                    'Unsupported type for %s: %s' %
+                    (name, type_),
+                )
+            if param_info.is_invasive:
+                self.acs.are_invasive_changes_applied = False
+            request.ParameterList.ParameterValueStruct.append(name_value)
+
+        return AcsMsgAndTransition(request, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Setting parameter values'
+
+
+class SetParameterValuesNotAdminState(EnodebAcsState):
+    def __init__(self, acs: EnodebAcsStateMachine, when_done: str):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        request = models.SetParameterValues()
+        request.ParameterList = models.ParameterValueList()
+        param_values = get_all_param_values_to_set(
+            self.acs.desired_cfg,
+            self.acs.device_cfg,
+            self.acs.data_model,
+            exclude_admin=True,
+        )
+        request.ParameterList.arrayType = 'cwmp:ParameterValueStruct[%d]' \
+                                          % len(param_values)
+        request.ParameterList.ParameterValueStruct = []
+        logger.debug(
+            'Sending TR069 request to set CPE parameter values: %s',
+            str(param_values),
+        )
+        for name, value in param_values.items():
+            param_info = self.acs.data_model.get_parameter(name)
+            type_ = param_info.type
+            name_value = models.ParameterValueStruct()
+            name_value.Value = models.anySimpleType()
+            name_value.Name = param_info.path
+            enb_value = self.acs.data_model.transform_for_enb(name, value)
+            if type_ in ('int', 'unsignedInt'):
+                name_value.Value.type = 'xsd:%s' % type_
+                name_value.Value.Data = str(enb_value)
+            elif type_ == 'boolean':
+                # Boolean values have integral representations in spec
+                name_value.Value.type = 'xsd:boolean'
+                name_value.Value.Data = str(int(enb_value))
+            elif type_ == 'string':
+                name_value.Value.type = 'xsd:string'
+                name_value.Value.Data = str(enb_value)
+            else:
+                raise Tr069Error(
+                    'Unsupported type for %s: %s' %
+                    (name, type_),
+                )
+            if param_info.is_invasive:
+                self.acs.are_invasive_changes_applied = False
+            request.ParameterList.ParameterValueStruct.append(name_value)
+
+        return AcsMsgAndTransition(request, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Setting parameter values excluding Admin Enable'
+
+
+class WaitSetParameterValuesState(EnodebAcsState):
+    def __init__(
+        self,
+        acs: EnodebAcsStateMachine,
+        when_done: str,
+        when_apply_invasive: str,
+        status_non_zero_allowed: bool = False,
+    ):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+        self.apply_invasive_transition = when_apply_invasive
+        # Set Params can legally return zero and non zero status
+        # Per tr-196, if there are errors the method should return a fault.
+        # Make flag optional to compensate for existing radios returning non
+        # zero on error.
+        self.status_non_zero_allowed = status_non_zero_allowed
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        if type(message) == models.SetParameterValuesResponse:
+            if not self.status_non_zero_allowed:
+                if message.Status != 0:
+                    raise Tr069Error(
+                        'Received SetParameterValuesResponse with '
+                        'Status=%d' % message.Status,
+                    )
+            self._mark_as_configured()
+            if not self.acs.are_invasive_changes_applied:
+                return AcsReadMsgResult(True, self.apply_invasive_transition)
+            return AcsReadMsgResult(True, self.done_transition)
+        elif type(message) == models.Fault:
+            logger.error(
+                'Received Fault in response to SetParameterValues, '
+                'Code (%s), Message (%s)', message.FaultCode,
+                message.FaultString,
+            )
+            if message.SetParameterValuesFault is not None:
+                for fault in message.SetParameterValuesFault:
+                    logger.error(
+                        'SetParameterValuesFault Param: %s, '
+                        'Code: %s, String: %s', fault.ParameterName,
+                        fault.FaultCode, fault.FaultString,
+                    )
+        return AcsReadMsgResult(False, None)
+
+    def _mark_as_configured(self) -> None:
+        """
+        A successful attempt at setting parameter values means that we need to
+        update what we think the eNB's configuration is to match what we just
+        set the parameter values to.
+        """
+        # Values of parameters
+        name_to_val = get_param_values_to_set(
+            self.acs.desired_cfg,
+            self.acs.device_cfg,
+            self.acs.data_model,
+        )
+        for name, val in name_to_val.items():
+            magma_val = self.acs.data_model.transform_for_magma(name, val)
+            self.acs.device_cfg.set_parameter(name, magma_val)
+
+        # Values of object parameters
+        obj_to_name_to_val = get_obj_param_values_to_set(
+            self.acs.desired_cfg,
+            self.acs.device_cfg,
+            self.acs.data_model,
+        )
+        for obj_name, name_to_val in obj_to_name_to_val.items():
+            for name, val in name_to_val.items():
+                logger.debug(
+                    'Set obj: %s, name: %s, val: %s', str(obj_name),
+                    str(name), str(val),
+                )
+                magma_val = self.acs.data_model.transform_for_magma(name, val)
+                self.acs.device_cfg.set_parameter_for_object(
+                    name, magma_val,
+                    obj_name,
+                )
+        logger.info('Successfully configured CPE parameters!')
+
+    def state_description(self) -> str:
+        return 'Setting parameter values'
+
+
+class EndSessionState(EnodebAcsState):
+    """ To end a TR-069 session, send an empty HTTP response """
+
+    def __init__(self, acs: EnodebAcsStateMachine):
+        super().__init__()
+        self.acs = acs
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        """
+        No message is expected after enodebd sends the eNodeB
+        an empty HTTP response.
+
+        If a device sends an empty HTTP request, we can just
+        ignore it and send another empty response.
+        """
+        if isinstance(message, models.DummyInput):
+            return AcsReadMsgResult(True, None)
+        return AcsReadMsgResult(False, None)
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        request = models.DummyInput()
+        return AcsMsgAndTransition(request, None)
+
+    def state_description(self) -> str:
+        return 'Completed provisioning eNB. Awaiting new Inform.'
+
+
+class EnbSendRebootState(EnodebAcsState):
+    def __init__(self, acs: EnodebAcsStateMachine, when_done: str):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+        self.prev_msg_was_inform = False
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        """
+        This state can be transitioned into through user command.
+        All messages received by enodebd will be ignored in this state.
+        """
+        if self.prev_msg_was_inform \
+                and not isinstance(message, models.DummyInput):
+            return AcsReadMsgResult(False, None)
+        elif isinstance(message, models.Inform):
+            self.prev_msg_was_inform = True
+            process_inform_message(
+                message, self.acs.data_model,
+                self.acs.device_cfg,
+            )
+            return AcsReadMsgResult(True, None)
+        self.prev_msg_was_inform = False
+        return AcsReadMsgResult(True, None)
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        if self.prev_msg_was_inform:
+            response = models.InformResponse()
+            # Set maxEnvelopes to 1, as per TR-069 spec
+            response.MaxEnvelopes = 1
+            return AcsMsgAndTransition(response, None)
+        logger.info('Sending reboot request to eNB')
+        request = models.Reboot()
+        request.CommandKey = ''
+        self.acs.are_invasive_changes_applied = True
+        return AcsMsgAndTransition(request, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Rebooting eNB'
+
+
+class SendRebootState(EnodebAcsState):
+    def __init__(self, acs: EnodebAcsStateMachine, when_done: str):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+        self.prev_msg_was_inform = False
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        """
+        This state can be transitioned into through user command.
+        All messages received by enodebd will be ignored in this state.
+        """
+        if self.prev_msg_was_inform \
+                and not isinstance(message, models.DummyInput):
+            return AcsReadMsgResult(False, None)
+        elif isinstance(message, models.Inform):
+            self.prev_msg_was_inform = True
+            process_inform_message(
+                message, self.acs.data_model,
+                self.acs.device_cfg,
+            )
+            return AcsReadMsgResult(True, None)
+        self.prev_msg_was_inform = False
+        return AcsReadMsgResult(True, None)
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        if self.prev_msg_was_inform:
+            response = models.InformResponse()
+            # Set maxEnvelopes to 1, as per TR-069 spec
+            response.MaxEnvelopes = 1
+            return AcsMsgAndTransition(response, None)
+        logger.info('Sending reboot request to eNB')
+        request = models.Reboot()
+        request.CommandKey = ''
+        return AcsMsgAndTransition(request, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Rebooting eNB'
+
+
+class WaitRebootResponseState(EnodebAcsState):
+    def __init__(self, acs: EnodebAcsStateMachine, when_done: str):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        if not isinstance(message, models.RebootResponse):
+            return AcsReadMsgResult(False, None)
+        return AcsReadMsgResult(True, None)
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        """ Reply with empty message """
+        return AcsMsgAndTransition(models.DummyInput(), self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Rebooting eNB'
+
+
+class WaitInformMRebootState(EnodebAcsState):
+    """
+    After sending a reboot request, we expect an Inform request with a
+    specific 'inform event code'
+    """
+
+    # Time to wait for eNodeB reboot. The measured time
+    # (on BaiCells indoor eNodeB)
+    # is ~110secs, so add healthy padding on top of this.
+    REBOOT_TIMEOUT = 300  # In seconds
+    # We expect that the Inform we receive tells us the eNB has rebooted
+    INFORM_EVENT_CODE = 'M Reboot'
+
+    def __init__(
+        self,
+        acs: EnodebAcsStateMachine,
+        when_done: str,
+        when_timeout: str,
+    ):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+        self.timeout_transition = when_timeout
+        self.timeout_timer = None
+        self.timer_handle = None
+
+    def enter(self):
+        self.timeout_timer = StateMachineTimer(self.REBOOT_TIMEOUT)
+
+        def check_timer() -> None:
+            if self.timeout_timer.is_done():
+                self.acs.transition(self.timeout_transition)
+                raise Tr069Error(
+                    'Did not receive Inform response after '
+                    'rebooting',
+                )
+
+        self.timer_handle = \
+            self.acs.event_loop.call_later(
+                self.REBOOT_TIMEOUT,
+                check_timer,
+            )
+
+    def exit(self):
+        self.timer_handle.cancel()
+        self.timeout_timer = None
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        if not isinstance(message, models.Inform):
+            return AcsReadMsgResult(False, None)
+        if not does_inform_have_event(message, self.INFORM_EVENT_CODE):
+            raise Tr069Error(
+                'Did not receive M Reboot event code in '
+                'Inform',
+            )
+        process_inform_message(
+            message, self.acs.data_model,
+            self.acs.device_cfg,
+        )
+        return AcsReadMsgResult(True, self.done_transition)
+
+    def state_description(self) -> str:
+        return 'Waiting for M Reboot code from Inform'
+
+
+class WaitRebootDelayState(EnodebAcsState):
+    """
+    After receiving the Inform notifying us that the eNodeB has successfully
+    rebooted, wait a short duration to prevent unspecified race conditions
+    that may occur w.r.t reboot
+    """
+
+    # Short delay timer to prevent race conditions w.r.t. reboot
+    SHORT_CONFIG_DELAY = 10
+
+    def __init__(self, acs: EnodebAcsStateMachine, when_done: str):
+        super().__init__()
+        self.acs = acs
+        self.done_transition = when_done
+        self.config_timer = None
+        self.timer_handle = None
+
+    def enter(self):
+        self.config_timer = StateMachineTimer(self.SHORT_CONFIG_DELAY)
+
+        def check_timer() -> None:
+            if self.config_timer.is_done():
+                self.acs.transition(self.done_transition)
+
+        self.timer_handle = \
+            self.acs.event_loop.call_later(
+                self.SHORT_CONFIG_DELAY,
+                check_timer,
+            )
+
+    def exit(self):
+        self.timer_handle.cancel()
+        self.config_timer = None
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        return AcsReadMsgResult(True, None)
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        return AcsMsgAndTransition(models.DummyInput(), None)
+
+    def state_description(self) -> str:
+        return 'Waiting after eNB reboot to prevent race conditions'
+
+
+class ErrorState(EnodebAcsState):
+    """
+    The eNB handler will enter this state when an unhandled Fault is received.
+
+    If the inform_transition_target constructor parameter is non-null, this
+    state will attempt to autoremediate by transitioning to the specified
+    target state when an Inform is received.
+    """
+
+    def __init__(
+        self, acs: EnodebAcsStateMachine,
+        inform_transition_target: Optional[str] = None,
+    ):
+        super().__init__()
+        self.acs = acs
+        self.inform_transition_target = inform_transition_target
+
+    def read_msg(self, message: Any) -> AcsReadMsgResult:
+        return AcsReadMsgResult(True, None)
+
+    def get_msg(self, message: Any) -> AcsMsgAndTransition:
+        if not self.inform_transition_target:
+            return AcsMsgAndTransition(models.DummyInput(), None)
+
+        if isinstance(message, models.Inform):
+            return AcsMsgAndTransition(
+                models.DummyInput(),
+                self.inform_transition_target,
+            )
+        return AcsMsgAndTransition(models.DummyInput(), None)
+
+    def state_description(self) -> str:
+        return 'Error state - awaiting manual restart of enodebd service or ' \
+               'an Inform to be received from the eNB'
diff --git a/state_machines/timer.py b/state_machines/timer.py
new file mode 100644
index 0000000..09f6b68
--- /dev/null
+++ b/state_machines/timer.py
@@ -0,0 +1,33 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+from datetime import datetime, timedelta
+
+
+class StateMachineTimer():
+    def __init__(self, seconds_remaining: int) -> None:
+        self.start_time = datetime.now()
+        self.seconds = seconds_remaining
+
+    def is_done(self) -> bool:
+        time_elapsed = datetime.now() - self.start_time
+        if time_elapsed > timedelta(seconds=self.seconds):
+            return True
+        return False
+
+    def seconds_elapsed(self) -> int:
+        time_elapsed = datetime.now() - self.start_time
+        return int(time_elapsed.total_seconds())
+
+    def seconds_remaining(self) -> int:
+        return max(0, self.seconds - self.seconds_elapsed())