device_m2.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. #!/usr/bin/env python3
  2. """Base classes for MT-M2 modems support"""
  3. __author__ = "Andrew Kolyaskin"
  4. __email__ = "a.kolyaskin@labinsys.ru"
  5. __date__ = "04.04.2019"
  6. __status__ = "testing"
  7. import os
  8. import json
  9. import sys
  10. import time
  11. import zlib
  12. import socket
  13. import subprocess
  14. from datetime import datetime
  15. from urllib.parse import urlencode
  16. from collections import OrderedDict
  17. class AnnounceM2:
  18. """Container and parser for M2 UDP announce data"""
  19. # '{model};{serial};{mac};{sw_ver};{button_set};{button_mode};{stm32_id};;{production_date};{status};{button_box};'
  20. SEP = ';'
  21. TRUE_S = 'true'
  22. __slots__ = ('model', 'serial', 'mac', 'sw_ver', 'button_set',
  23. 'button_mode', 'stm32_id', 'production_date', 'status', 'button_box')
  24. def __init__(self, model, serial, mac, sw_ver, button_set, button_mode, stm32_id, prod_date, status, button_box):
  25. self.model, self.serial, self.mac, self.sw_ver = model, serial, mac, sw_ver
  26. self.button_set, self.button_mode, self.stm32_id = button_set, button_mode, stm32_id
  27. self.production_date, self.status, self.button_box = prod_date, status, button_box
  28. @classmethod
  29. def parse(cls, raw):
  30. """Parse raw bytes from received datagram"""
  31. if not isinstance(raw, str):
  32. try:
  33. raw = raw.decode()
  34. except Exception as e:
  35. return
  36. sp = raw.split(cls.SEP)
  37. if len(sp) != 12:
  38. return None
  39. true = cls.TRUE_S
  40. return cls(sp[0], sp[1], sp[2], sp[3], sp[4] == true, sp[5] == true, sp[6], sp[8], sp[9], sp[10] == true)
  41. class DeviceM2Error(Exception):
  42. """Exception class for M2 device"""
  43. class DeviceM2AuthFail(DeviceM2Error):
  44. """This exception raised on possible auth fail (example: got auth page instead of JSON)"""
  45. class DeviceM2ConnectionFail(DeviceM2Error):
  46. """This exception raised on socket connecting fail"""
  47. class DeviceM2:
  48. """Metrolog-M2 board service HTTP client"""
  49. TRY_COUNT = 3
  50. TIMEOUT = 5
  51. WAIT_TIME = 3
  52. HTTP_PORT = 80
  53. ANNOUNCE_PORT = 49049
  54. T1READY_STATUS = 'T0OK'
  55. MODEL = 'Метролог M2'
  56. SETTINGS_FORM_KEYS = ('cursor', 'eth_ena', 'eth_prior', 'eth_ip_test', 'gsm1_prior', 'gsm1_ip_test', 'gsm2_prior',
  57. 'gsm2_ip_test', 'ipaddr', 'gw', 'mask', 'gsm1_ena', 'gsm1_profile', 'gsm1_apn', 'gsm1_login',
  58. 'gsm1_passw', 'gsm2_ena', 'gsm2_profile', 'gsm2_apn', 'gsm2_login', 'gsm2_passw', 'srv_ip',
  59. 'srv_port', 'pgw1_en', 'pgw1_dscr', 'pgw1_rs', 'pgw1_baud', 'pgw1_par', 'pgw1_ndata',
  60. 'pgw1_nstop', 'pgw1_mode', 'pgw1_trans', 'pgw1_port', 'pgw2_dscr', 'pgw2_rs', 'pgw2_baud',
  61. 'pgw2_par', 'pgw2_ndata', 'pgw2_nstop', 'pgw2_mode', 'pgw2_trans', 'pgw2_port', 'pgw3_dscr',
  62. 'pgw3_rs', 'pgw3_baud', 'pgw3_par', 'pgw3_ndata', 'pgw3_nstop', 'pgw3_mode', 'pgw3_trans',
  63. 'pgw3_port', 'ntpservip1', 'ntpservip2', 'password', 'utc', 'ntp', 'date', 'time')
  64. def __init__(self, ip, params=None):
  65. self.ip = ip
  66. if isinstance(params, AnnounceM2):
  67. self.model, self.serial, self.mac = params.model, params.serial, params.mac
  68. self.sw_ver, self.stm32_id, self.status = params.sw_ver, params.stm32_id, params.status
  69. else:
  70. self.model = self.serial = self.mac = self.sw_ver = self.stm32_id = self.status = None
  71. self.cookies = ''
  72. def ping(self):
  73. """Test connection with device using ping"""
  74. null = open(os.devnull, 'wb')
  75. try:
  76. subprocess.check_call(['ping', '-c', '5', '-i', '.8', '-w', '15', '-W', '1', self.ip],
  77. timeout=16, stdout=null, stderr=null)
  78. return True
  79. except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
  80. return False
  81. def _request(self, path, method='GET', body='', parse_json=True, add_timestamp=False, no_response=False, timeout=0):
  82. """Do HTTP request using raw TCP"""
  83. sock = socket.socket()
  84. sock.settimeout(timeout if timeout else self.TIMEOUT)
  85. try:
  86. sock.connect((self.ip, self.HTTP_PORT))
  87. if add_timestamp:
  88. path = '{}?_={}'.format(path, int(time.time() * 1000))
  89. req = '{} {} HTTP/1.1\r\nContent-Length: {}\r\nCookies: {}\r\n\r\n{}'.format(
  90. method, path, len(body), self.cookies, body).encode()
  91. sock.sendall(req)
  92. time.sleep(self.WAIT_TIME)
  93. res = None if no_response else sock.recv(32768)
  94. except socket.timeout:
  95. raise DeviceM2Error('Тайм-аут связи с тестируемым устройством')
  96. except Exception as e:
  97. print(e)
  98. raise DeviceM2ConnectionFail('Ошибка связи с тестируемым устройством')
  99. else:
  100. if parse_json and not no_response:
  101. try:
  102. body = res.split(b'\r\n\r\n')[1]
  103. return json.loads(body.decode())
  104. except UnicodeDecodeError:
  105. try:
  106. html = zlib.decompress(body, 16 + zlib.MAX_WBITS).decode()
  107. if 'action="login.cgi"' in html and '<h1>Авторизация</h1>' in html:
  108. raise DeviceM2AuthFail('Ошибка авторизации')
  109. except (zlib.error, UnicodeDecodeError):
  110. raise DeviceM2Error('Некорректный JSON-ответ')
  111. except (json.JSONDecodeError, IndexError):
  112. raise DeviceM2Error('Некорректный JSON-ответ')
  113. else:
  114. return res
  115. @staticmethod
  116. def announce_socket():
  117. """Create socket to receive announce messages"""
  118. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  119. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  120. sock.settimeout(.5)
  121. sock.bind(('0.0.0.0', DeviceM2.ANNOUNCE_PORT))
  122. return sock
  123. @staticmethod
  124. def parse_uptime_str(uptime_str):
  125. """Parse ru uptime string, return result in seconds '0 дн. 0 ч. 5 мин.'"""
  126. d, h, m = map(int, uptime_str.split()[::2])
  127. return 86400 * d + 3600 * h + 60 * m
  128. def wait_announce(self, timeout=None, get_ip=False):
  129. """Wait next UDP announce from device"""
  130. if timeout is None:
  131. timeout = self.TIMEOUT
  132. sock = self.announce_socket()
  133. start = datetime.now()
  134. while True:
  135. try:
  136. raw, address = sock.recvfrom(4096)
  137. except socket.timeout:
  138. pass
  139. else:
  140. announce = AnnounceM2.parse(raw)
  141. if announce is not None:
  142. if announce.stm32_id == self.stm32_id:
  143. return (address, announce) if get_ip else announce
  144. finally:
  145. if (datetime.now() - start).seconds > timeout:
  146. raise DeviceM2Error('Превышено время ожидания анонса')
  147. def login(self):
  148. """Log in to web interface of device"""
  149. if 'M3' in str(self.model):
  150. login, password = 'user', 'uchetmo'
  151. else:
  152. login, password = 'admin', '12345'
  153. cookie_mark = b'Set-Cookie: '
  154. res = self._request('/login.cgi', 'POST', f'login={login}&password={password}', False)
  155. head = res.split(b'\r\n\r\n')[0]
  156. self.cookies = b'; '.join(i[len(cookie_mark):] for i in filter(lambda x: x.startswith(cookie_mark),
  157. head.split(b'\r\n'))).decode()
  158. return bool(self.cookies)
  159. def dump_cookies(self, path):
  160. """Save cookies to file"""
  161. with open(path, 'w') as f:
  162. f.write(self.cookies)
  163. def load_cookies(self, path):
  164. """Load cookies from file"""
  165. with open(path) as f:
  166. self.cookies = f.read()
  167. def reboot(self):
  168. """Send reboot CGI request, do not wait response"""
  169. self._request('/reboot.cgi', add_timestamp=True, no_response=True)
  170. def update_fw_m2(self, fw_path, part=4096, callback=None):
  171. """Update M2 firmware from file"""
  172. boundary = '---------------------------69299438174861'
  173. data = open(fw_path, 'rb').read()
  174. sock = socket.socket()
  175. sock.connect((self.ip, self.HTTP_PORT))
  176. body = b''.join((b'-----------------------------69299438174861\r\n'
  177. b'Content-Disposition: form-data; name="file1"; filename="MT_M01.bin"\r\n'
  178. b'Content-Type: application/octet-stream\r\n\r\n', data,
  179. b'\r\n-----------------------------69299438174861--\r\n'))
  180. req = 'POST /upload.cgi HTTP/1.1\r\nContent-Type: multipart/form-data; boundary={}\r\n' \
  181. 'Content-Length: {}\r\nCookies: {}\r\n\r\n'.format(boundary, len(body), self.cookies).encode()
  182. request = req + body
  183. length = len(request)
  184. for i in range(0, length, part):
  185. sock.sendall(request[i: i + part])
  186. if callable(callback):
  187. callback(i, part, length)
  188. res = sock.recv(4096)
  189. return b'200 OK' in res and res.endswith(b'1')
  190. def update_fw_m3(self, fw_path, part=800, callback=None):
  191. """Update M3 firmware from file"""
  192. boundary = '---------------------------69299438174861'
  193. data = open(fw_path, 'rb').read()
  194. sock = socket.socket()
  195. # sock.setsockopt(socket.SOL_TCP, socket.TCP_MAXSEG, 1300)
  196. sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
  197. sock.connect((self.ip, self.HTTP_PORT))
  198. sock.settimeout(1)
  199. body = b''.join((b'-----------------------------69299438174861\r\n'
  200. b'Content-Disposition: form-data; name="file1"; filename="MT_M03.bin"\r\n'
  201. b'Content-Type: application/octet-stream\r\n\r\n', data,
  202. b'\r\n-----------------------------69299438174861--\r\n'))
  203. req = 'POST /upload.cgi HTTP/1.1\r\nContent-Type: multipart/form-data; boundary={}\r\n' \
  204. 'Content-Length: {}\r\nCookies: {}\r\n\r\n'.format(boundary, len(body), self.cookies).encode()
  205. request = req + body
  206. length = len(request)
  207. for i in range(0, length, part):
  208. chunk = i + part
  209. if chunk > length:
  210. chunk = length
  211. offset = 0
  212. current_part_length = chunk - i
  213. while offset < current_part_length:
  214. try:
  215. r = sock.send(request[i + offset: chunk])
  216. except (BlockingIOError, socket.timeout):
  217. r = 0
  218. print(i)
  219. print(f'Sent {r} bytes (from {i + offset} to {chunk} of {length})')
  220. offset += r
  221. if offset < current_part_length:
  222. print('sleep on fail')
  223. time.sleep(2)
  224. else:
  225. print('sleep on ok')
  226. time.sleep(1)
  227. if callable(callback):
  228. callback(i, part, length)
  229. time.sleep(1)
  230. start_time = time.monotonic()
  231. res = b'No any data'
  232. print('Reading response...')
  233. while time.monotonic() - start_time < 120:
  234. try:
  235. print(f'Try recv...')
  236. res = sock.recv(4096)
  237. print(res)
  238. break
  239. except (BlockingIOError, socket.timeout) as e:
  240. time.sleep(0.5)
  241. print(e)
  242. res = b'No response'
  243. print(res)
  244. return b'200 OK' in res and res.endswith(b'1')
  245. def update_fw(self, fw_path, part=4096, callback=None):
  246. """Update Mx firmware from file"""
  247. if 'M3' in str(self.model):
  248. return self.update_fw_m3(fw_path, part, callback)
  249. else:
  250. return self.update_fw_m2(fw_path, part, callback)
  251. def set_system_variables(self, serial, mac, production_date_str):
  252. """Set system variables from dictionary of strings for device through HTTP GET request"""
  253. sysvars_dict = dict(serial=serial, mac=mac, proddate=production_date_str)
  254. print(sysvars_dict)
  255. try:
  256. res = self._request('/service_set_sysvars.cgi?{}'.format(urlencode(sysvars_dict)), parse_json=False)
  257. except DeviceM2ConnectionFail:
  258. res = self._request('/service_set_sysvars.cgi?{}'.format(urlencode(sysvars_dict)), parse_json=False)
  259. if b'200' in res:
  260. print("return 200")
  261. time.sleep(1)
  262. a = self.wait_announce()
  263. return a
  264. else:
  265. DeviceM2Error('Ошибка при установке системных переменных')
  266. def set_status(self, status):
  267. """Set system variables from dictionary of strings for device through HTTP GET request"""
  268. sysvars_dict = dict(status_fail=status)
  269. try:
  270. self._request('/service_set_test_state.cgi?{}'.format(urlencode(sysvars_dict)), parse_json=False)
  271. except DeviceM2Error:
  272. pass
  273. return self.wait_announce().status == status
  274. def set_led(self, led, color, state, freq=None):
  275. """Control M2 LEDs: server and status, color ::= r | g, state ::= on | off | blink"""
  276. assert led in ('server', 'status')
  277. assert color in ('r', 'g')
  278. assert state in ('on', 'off', 'blink')
  279. url = '/service_led.cgi?led={}&color={}&state={}'.format(led, color, state)
  280. if freq is not None:
  281. url += '&freq={}'.format(int(freq))
  282. self._request(url, parse_json=False)
  283. def reboot_to_iap(self):
  284. """Reboot device to IAP mode using GET request (for M3 only now)"""
  285. return self._request('/goboot.cgi', no_response=True)
  286. def check_iap(self):
  287. """Check if M2 device is in IAP mode (for M3 only now)"""
  288. return b'url=/upload.html' in self._request('/', parse_json=False)
  289. def get_info(self):
  290. """Get info JSON from WEB interface"""
  291. return self._request('/info.cgi', add_timestamp=True)
  292. def get_p_info(self):
  293. """Get info for long test from special CGI page"""
  294. return self._request('/progon.cgi')
  295. def get_settings(self):
  296. """Get settings JSON from WEB interface"""
  297. return self._request('/settings.cgi', add_timestamp=True)
  298. def confirm(self):
  299. """Send reboot CGI request, do not wait response"""
  300. self._request('/confirm.cgi', add_timestamp=True, no_response=False, parse_json=False)
  301. @staticmethod
  302. def _rectify_settings(settings):
  303. """Convert settings JSON from tree to plain structure"""
  304. up = dict()
  305. for i, obj in enumerate(settings.pop('gsm')):
  306. for k, v in obj.items():
  307. up['gsm{}_{}'.format(i + 1, k[4:])] = v
  308. for i, obj in enumerate(settings.pop('pgw')):
  309. for k, v in obj.items():
  310. up['pgw{}_{}'.format(i + 1, k[4:])] = v
  311. settings.update(up)
  312. return settings
  313. @staticmethod
  314. def _to_http_form_value(value):
  315. """Convert value to HTTP form format"""
  316. if isinstance(value, bool):
  317. return 'on' if value else ''
  318. elif isinstance(value, float):
  319. return str(int(value))
  320. else:
  321. return str(value)
  322. def _set_settings(self, url, no_response, settings_dict):
  323. """Common function for changing M2 settings using HTTP form POSTing"""
  324. settings = self.get_settings()
  325. r_settings = self._rectify_settings(settings)
  326. new_settings = OrderedDict()
  327. new_settings['cursor'] = '0'
  328. new_settings['password'] = ''
  329. for k in self.SETTINGS_FORM_KEYS:
  330. if k in r_settings:
  331. new_settings[k] = self._to_http_form_value(r_settings[k])
  332. for k, v in settings_dict.items():
  333. if v is None:
  334. if k in new_settings:
  335. new_settings.pop(k)
  336. else:
  337. new_settings[k] = v
  338. return self._request(url, 'POST', urlencode(new_settings), False, False, no_response)
  339. def set_settings(self, **kwargs):
  340. """Set ordinary settings for M2"""
  341. return self._set_settings('/settings.cgi', False, kwargs)
  342. def set_settings_reboot(self, **kwargs):
  343. """Set settings causes reboot for M2 - do not wait response"""
  344. return self._set_settings('/settings.cgi', True, kwargs)
  345. def set_settings_autoconfirm(self, **kwargs):
  346. """Set settings with auto-confirmation for M2, use service URL"""
  347. return self._set_settings('/settings_service.cgi', False, kwargs)
  348. def reset_settings(self):
  349. """Reset device settings to default"""
  350. self._request('/reset.cgi', parse_json=False, add_timestamp=True)
  351. def main(ip: str, path: str):
  352. #d = DeviceM2('178.176.41.47')
  353. #d = DeviceM2('192.168.10.254')
  354. d = DeviceM2(ip)
  355. d.model = 'M3'
  356. d.login()
  357. # d.set_led('status', 'g', 'on')
  358. # print(d.get_info())
  359. print(d.set_system_variables('7347382', 'EC-4C-4D-02-0E-D6', '05.07.2023+17:41'))
  360. # l = d.login()
  361. # print(f'Login - {l}')
  362. # d.update_fw_m3(path)
  363. # time.sleep(10)
  364. """
  365. {'uptime': '0 дн. 0 ч. 4 мин.', 'comment': '', 'incharge': '', 'digit_id': 0.0, 'fwversion': '2.014', 'iapversion': '1.03', 'owner': '', 'macaddr': 'EC-4C-4D-02-0E-D6', 'model': 'Метролог M3', 'mboard_rev': '7', 'dboard_rev': '7', 'mfr': 'АО "НПК РоТеК"', 'sysLocation': '', 'prodate': '06.07.2023+17:41', 'serno': '7347382', 'netsettings_changed': False, 'utm': 1760983071.0}
  366. """
  367. if __name__ == '__main__':
  368. main("192.168.1.101", "fw.bin")
  369. # if len(sys.argv) != 3:
  370. # print(f'Usage: {sys.argv[0]} ip fw_binary_path')
  371. # sys.exit(2)
  372. # sys.exit(main(sys.argv[1], sys.argv[2]))