# SPDX-License-Identifier: MPL-2.0 import importlib.util import json from pathlib import Path import socket import struct import sys import tempfile import unittest from unittest.mock import patch DEVICE=Path(__file__).parents[1]/'devices/beatles' sys.path.insert(0,str(DEVICE)) import radio_control as control import sms_pdu as sms from radio_status import Parcel HELLO='00040A91214365870900006201812100008005E8329BFD06' class PduTests(unittest.TestCase): def test_standard_gsm_vector_and_unicode(self): self.assertEqual(sms.pack7(sms.septets('hello')).hex(),'e8329bfd06') self.assertEqual(sms.submit('+1234567890','hello'),'01000A912143658709000005E8329BFD06') row=sms.deliver(HELLO) self.assertEqual(row['sender'],'+1234567890');self.assertEqual(row['body'],'hello') self.assertEqual(row['timestamp'],1792317600000) self.assertEqual(sms.submit('123','漢').lower(),'0100038121f30008026f22') def test_extensions_and_length_limits(self): text='é € {} [] ^ ~ \\';values=sms.septets(text) self.assertEqual(sms.unpack7(sms.pack7(values),len(values)),text) sms.submit('123','A'*160) for text in ('A'*161,'€'*81,'漢'*71): with self.assertRaisesRegex(ValueError,'SMS_TOO_LONG'):sms.submit('123',text) for value in ('112;reboot','*#06#','',None,'+123\n'): with self.assertRaises(ValueError):sms.submit(value,'hello') def test_truncated_and_unsupported_pdus(self): for end in range(0,len(HELLO),2): with self.assertRaises((ValueError,UnicodeError)):sms.deliver(HELLO[:end]) for pdu in ('00ff','xx','00040A9121436587097F006201812100008005E8329BFD06'): with self.assertRaises(ValueError):sms.deliver(pdu) def test_unicode_and_concatenation(self): prefix='00440A912143658709000862018121000080' row=sms.deliver(prefix+'080500030702010041') self.assertEqual(row['body'],'A');self.assertEqual(row['concat'],(7,2,1)) with tempfile.TemporaryDirectory() as directory: path=Path(directory)/'inbox.json';inbox=control.Inbox(path) inbox.receive(prefix+'080500030702020042');self.assertEqual(inbox.value['inbox'],[]) inbox.receive(prefix+'080500030702010041') self.assertEqual(inbox.value['inbox'][0]['body'],'AB') self.assertEqual(control.Inbox(path).value['inbox'][0]['body'],'AB') def test_durable_inbox_dedup_and_failed_save(self): with tempfile.TemporaryDirectory() as directory: path=Path(directory)/'inbox.json';inbox=control.Inbox(path) with patch.object(inbox,'save',side_effect=OSError('disk full')): with self.assertRaises(OSError):inbox.receive(HELLO) self.assertEqual(inbox.value['seen'],{}) inbox.receive(HELLO);inbox.receive(HELLO) self.assertEqual(len(inbox.value['inbox']),1) self.assertEqual(path.stat().st_mode&0o777,0o600) receipt=inbox.value['inbox'][0]['receipt'];inbox.acknowledge([receipt]) inbox.receive(HELLO);self.assertEqual(inbox.value['inbox'],[]) class ControlTests(unittest.TestCase): def test_unanswered_request_is_not_sent_again(self): radio=control.ControlRadio('/unused',0,None) radio.pending={10:(9,0)} broker=control.Broker([radio],None) with patch.object(radio,'send') as send: with self.assertRaisesRegex(RuntimeError,'RADIO_BUSY'):broker.exchange(9) send.assert_not_called() def test_call_list_timeout_is_short_and_request_stays_pending(self): radio=control.ControlRadio('/unused',0,None);radio.sock=unittest.mock.Mock() broker=control.Broker([radio],None) with patch.object(control.time,'monotonic',side_effect=[0,0,1,2,3]),patch.object(broker,'step'): with self.assertRaisesRegex(RuntimeError,'RADIO_TIMEOUT'):broker.exchange(9) self.assertIsNone(radio.waiting) self.assertEqual(len(radio.pending),1) def test_call_layout_and_caller_privacy(self): call=control.integer(1,4,2,145,0,1,0,1,0)+control.string('123456789')+control.integer(0)+control.string(None)+control.integer(0,0) self.assertEqual(control.current_calls(Parcel(call)),[{'index':2,'state':'incoming','number':'+123456789','direction':'incoming'}]) hidden=control.integer(1,4,2,145,0,1,0,1,0)+control.string('123456789')+control.integer(1)+control.string(None)+control.integer(0,0) self.assertEqual(control.current_calls(Parcel(hidden))[0]['number'],'') self.assertEqual(control.current_calls(Parcel(b'')),[]) def test_late_modem_reply_does_not_complete_another_request(self): radio=control.ControlRadio('/unused',0,None);radio.waiting=(9,20) radio.response(9,0,Parcel(control.integer(0)),19);self.assertIsNone(radio.result) radio.response(9,0,Parcel(control.integer(0)),20);self.assertEqual(radio.result,(True,[])) def test_malformed_call_list_does_not_drop_radio_transport(self): radio=control.ControlRadio('/unused',0,None);radio.waiting=(9,20) radio.sock=unittest.mock.Mock() radio.response(9,0,Parcel(control.integer(1)),20) self.assertEqual(radio.result,(False,'RADIO_INVALID_CALL_LIST')) radio.sock.close.assert_not_called() def test_unknown_operations_do_not_reach_the_modem(self): broker=control.Broker([],None) with patch.object(broker,'exchange') as exchange: for value in ({'version':1,'operation':'shell'},{'operation':'sms'},[],None): with self.assertRaises(ValueError):broker.request(value) exchange.assert_not_called() def test_calls_answer_hangup_and_sms_parcels(self): radio=control.ControlRadio('/unused',0,None);radio.state={'voice':{'connected':True}} broker=control.Broker([radio],None) row={'index':2,'state':'incoming','number':'123','direction':'incoming'} with patch.object(broker,'exchange',return_value=[row]) as exchange: calls=broker.request({'version':1,'operation':'calls'});path=calls[0]['path'] broker.request({'version':1,'operation':'hangup','path':path}) self.assertEqual(exchange.call_args.args,(12,control.integer(1,2))) broker.request({'version':1,'operation':'answer','path':path}) self.assertEqual(exchange.call_args.args,(40,)) with self.assertRaisesRegex(ValueError,'INVALID_CALL'): broker.request({'version':1,'operation':'hangup','path':'/stale/call/2'}) with patch.object(broker,'exchange',return_value={'state':'sent'}) as exchange: self.assertEqual(broker.request({'version':1,'operation':'sms','number':'123','body':'hello'}),{'state':'sent'}) self.assertEqual(exchange.call_args.args,(25,control.integer(2)+control.string(None)+control.string(sms.submit('123','hello')))) def test_second_call_is_rejected_before_dial(self): radio=control.ControlRadio('/unused',0,None);radio.state={'voice':{'connected':True}} broker=control.Broker([radio],None) with patch.object(broker,'calls',return_value=[{'path':'/live/call/1'}]),patch.object(broker,'exchange') as exchange: with self.assertRaisesRegex(RuntimeError,'RADIO_BUSY'):broker.request({'version':1,'operation':'dial','number':'123'}) exchange.assert_not_called() def test_sms_ack_only_after_storage(self): inbox=unittest.mock.Mock();radio=control.ControlRadio('/unused',0,inbox) with patch.object(radio,'send') as send: radio.unsolicited(1003,Parcel(control.string(HELLO))) inbox.receive.assert_called_once_with(HELLO);send.assert_called_with(37,control.integer(2,1,0)) inbox.receive.side_effect=OSError('full') radio.unsolicited(1003,Parcel(control.string(HELLO))) send.assert_called_with(37,control.integer(2,0,255)) class RadioPowerTests(unittest.TestCase): def broker(self): radios=[control.ControlRadio('/unused',i,None) for i in range(2)] for radio in radios: radio.state={'radioState':'enabled'};radio.sock=unittest.mock.Mock() return control.Broker(radios,None) def test_slot_payload_readback_and_restore(self): broker=self.broker() def exchange(request,payload,slot=0): self.assertEqual(request,23) count,enabled=struct.unpack('