blob: f81da3260d2caed2d472f92c2cc6ef9f2d157392 [file] [log] [blame]
Zack Williams90726642021-03-30 18:10:09 -07001#!/usr/bin/env python3
2
3# Copyright 2020-present Open Networking Foundation
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License
16
17import sys
18import os
19import json
20import logging
21import enum
22import requests
23import time
24import serial
25import subprocess
26from collections import namedtuple
27
28"""
29"Simple" script that checks Aether network operational status periodically
30by controlling the attached 4G/LTE modem with AT commands and
31report the result to the central monitoring server.
32"""
33
34
35CONF = json.loads(
36 open(os.getenv("CONFIG_FILE", "./config.json")).read(),
37 object_hook=lambda d: namedtuple("X", d.keys())(*d.values()),
38)
39
40logging.basicConfig(
41 filename=CONF.log_file,
42 format="%(asctime)s [%(levelname)s] %(message)s",
43 level=logging.ERROR,
44)
45
46report = {
47 "name": CONF.edge_name,
48 "status": {"control_plane": None, "user_plane": None},
49 "speedtest": {
50 "ping": {"dns": {"min": None, "avg": None, "max": None, "stddev": None}}
51 },
52}
53
54
55class State(enum.Enum):
56 error = "-1"
57 disconnected = "0"
58 connected = "1"
59
60 @classmethod
61 def has_value(cls, value):
62 return value in cls._value2member_map_
63
64
65class Modem:
66 log = logging.getLogger("aether_edge_monitoring.Modem")
67
68 read_timeout = 0.1
69
70 def __init__(self, port, baudrate):
71 self.port = port
72 self.baudrate = baudrate
73 self._response = None
74
75 def connect(self):
76 self.serial = serial.Serial(port=self.port, baudrate=self.baudrate, timeout=1)
77
78 def _write(self, command):
79 if self.serial.inWaiting() > 0:
80 self.serial.flushInput()
81
82 self._response = b""
83
84 self.serial.write(bytearray(command + "\r", "ascii"))
85 read = self.serial.inWaiting()
86 while True:
87 if read > 0:
88 self._response += self.serial.read(read)
89 else:
90 time.sleep(self.read_timeout)
91 read = self.serial.inWaiting()
92 if read == 0:
93 break
94 return self._response.decode("ascii").replace("\r\n", " ")
95
96 def write(self, command, wait_resp=True):
97 response = self._write(command)
98 self.log.debug("%s: %s", command, response)
99
100 if wait_resp and "ERROR" in response:
101 return False, None
102 return True, response
103
104 def is_connected(self):
105 success, result = self.write("AT+CGATT?")
106 if not success or "CGATT:" not in result:
107 return State.error
108 state = result.split("CGATT:")[1].split(" ")[0]
109 return State(state)
110
111 def close(self):
112 self.serial.close()
113
114
115def get_control_plane_state(modem):
116 # Delete the existing session
117 # "echo" works more stable than serial for this action
118 # success, result = modem.write('AT+CFUN=0')
119 logging.debug("echo 'AT+CFUN=0' > " + CONF.modem.port)
120 success = os.system("echo 'AT+CFUN=0' > " + CONF.modem.port)
121 logging.debug("result: %s", success)
122 if success is not 0:
123 msg = "Write 'AT+CFUN=0' failed"
124 logging.error(msg)
125 return State.error, msg
126
127 # Wait until the modem is fully disconnected
128 retry = 0
129 state = None
130 while retry < 5:
131 state = modem.is_connected()
132 if state is State.disconnected:
133 break
134 time.sleep(1)
135 retry += 1
136
137 # Consider the modem is not responding if disconnection failed
138 if state is not State.disconnected:
139 msg = "Failed to disconnect."
140 logging.error(msg)
141 return State.error, msg
142
143 time.sleep(2)
144 # Create a new session
145 # "echo" works more stable than serial for this action
146 # success, result = modem.write('AT+CGATT=1')
147 logging.debug("echo 'AT+CFUN=1' > " + CONF.modem.port)
148 success = os.system("echo 'AT+CFUN=1' > " + CONF.modem.port)
149 logging.debug("result: %s", success)
150 if success is not 0:
151 msg = "Write 'AT+CFUN=1' failed"
152 logging.error(msg)
153 return State.error, msg
154
155 # Give 10 sec for the modem to be fully connected
156 retry = 0
157 while retry < 30:
158 state = modem.is_connected()
159 if state is State.connected:
160 break
161 time.sleep(1)
162 retry += 1
163 # CGATT sometimes returns None
164 if state is State.error:
165 state = State.disconnected
166
167 time.sleep(2)
168 return state, None
169
170
171def get_user_plane_state(modem):
172 resp = os.system("ping -c 3 " + CONF.ips.user_plane_ping_test + ">/dev/null 2>&1")
173 return State.connected if resp is 0 else State.disconnected, None
174
175
176def run_ping_test(ip, count):
177 """
178 Runs the ping test
179 Input: IP to ping, # times to ping
180 Returns: dict of the min/avg/max/stddev numbers from the ping command result
181 """
182 result = {"min": 0.0, "avg": 0.0, "max": 0.0, "stddev": 0.0}
183 try:
184 pingResult = (
185 subprocess.check_output(
186 "ping -c " + str(count) + " " + ip + " | tail -1 | awk '{print $4}'",
187 shell=True,
188 )
189 .decode("UTF-8")
190 .split("/")
191 )
192 result = {
193 "min": float(pingResult[0]),
194 "avg": float(pingResult[1]),
195 "max": float(pingResult[2]),
196 "stddev": float(pingResult[3]),
197 }
198 except Exception as e:
199 logging.error("Ping test failed for " + ip + ": %s", e)
200 return result
201
202
203def get_ping_test(modem):
204 """
205 Each ping test result saves the min/avg/max/stddev to dict.
206 1) Performs ping test to Google Public DNS for 10 iterations.
207 2) # TODO: Performs ping to device on network.
208 """
209 speedtest_ping = {}
210 speedtest_ping["dns"] = run_ping_test(CONF.ips.speedtest_ping_dns, 10)
211 return speedtest_ping
212
213
214def report_status(cp_state, up_state, speedtest_ping):
215 report["status"]["control_plane"] = cp_state.name
216 report["status"]["user_plane"] = up_state.name
217 report["speedtest"]["ping"] = speedtest_ping
218
219 logging.info("Sending report %s", report)
220 try:
221 result = requests.post(CONF.report_url, json=report)
222 except requests.exceptions.ConnectionError:
223 logging.error("Failed to report for %s", e)
224 pass
225 try:
226 result.raise_for_status()
227 except requests.exceptions.HTTPError as e:
228 logging.error("Failed to report for %s", e)
229 pass
230
231
232def main():
233 modem = Modem(CONF.modem.port, CONF.modem.baud)
234 try:
235 modem.connect()
236 except serial.serialutil.SerialException as e:
237 logging.error("Failed to connect the modem for %s", e)
238 sys.exit(1)
239
240 for ip in CONF.ips:
241 success = os.system(
242 "sudo ip route replace {}/32 via {}".format(ip, CONF.modem.ip_addr)
243 )
244 if success is not 0:
245 logging.error("Failed to add test routing to " + ip)
246 sys.exit(1)
247
248 while True:
249 cp_state, cp_msg = get_control_plane_state(modem)
250 up_state, up_msg = get_user_plane_state(modem)
251 speedtest_ping = get_ping_test(modem)
252
253 if cp_state is State.error:
254 logging.error("Modem is in error state.")
255 sys.exit(1)
256
257 report_status(cp_state, up_state, speedtest_ping)
258 time.sleep(CONF.report_interval)
259
260 modem.close()
261
262
263if __name__ == "__main__":
264 main()