Yet Another Denon Python Module
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

408 lines
8.1 KiB

  1. #!/usr/bin/env python
  2. import mock
  3. import serial
  4. import unittest
  5. import threading
  6. import time
  7. class DenonAVR(object):
  8. def __init__(self, serdev):
  9. self._ser = serial.serial_for_url(serdev, baudrate=9600,
  10. timeout=.5)
  11. self._power = None
  12. self._vol = None
  13. self._volmax = None
  14. self._speakera = None
  15. self._speakerb = None
  16. self._z2mute = None
  17. self._zm = None
  18. self._ms = None
  19. @property
  20. def ms(self):
  21. 'Surround mode'
  22. return self._ms
  23. @property
  24. def power(self):
  25. 'Power status, True if on'
  26. return self._power
  27. @power.setter
  28. def power(self, arg):
  29. arg = bool(arg)
  30. if arg != self._power:
  31. args = { True: 'ON', False: 'STANDBY' }
  32. self._sendcmd('PW', args[arg])
  33. self.process_events(till='PW')
  34. time.sleep(1)
  35. self.update()
  36. @staticmethod
  37. def _makevolarg(arg):
  38. arg = int(arg)
  39. if arg < 0 or arg > 99:
  40. raise ValueError('Volume out of range.')
  41. arg -= 1
  42. arg %= 100
  43. return '%02d' % arg
  44. @staticmethod
  45. def _parsevolarg(arg):
  46. arg = int(arg)
  47. if arg < 0 or arg > 99:
  48. raise ValueError('Volume out of range.')
  49. arg += 1
  50. arg %= 100
  51. return arg
  52. @property
  53. def vol(self):
  54. 'Volumn, range 0 through 99'
  55. return self._vol
  56. @vol.setter
  57. def vol(self, arg):
  58. if arg == self._vol:
  59. return
  60. if self._volmax is not None and arg > self._volmax:
  61. raise ValueError('volume %d, exceeds max: %d' % (arg,
  62. self._volmax))
  63. arg = self._makevolarg(arg)
  64. time.sleep(1)
  65. self._sendcmd('MV', arg)
  66. self.process_events(till='MV')
  67. self.process_events(till='MV')
  68. @property
  69. def volmax(self):
  70. 'Maximum volume supported.'
  71. return self._volmax
  72. def proc_PW(self, arg):
  73. if arg == 'STANDBY':
  74. self._power = False
  75. elif arg == 'ON':
  76. self._power = True
  77. else:
  78. raise RuntimeError('unknown PW arg: %s' % `arg`)
  79. def proc_MU(self, arg):
  80. if arg == 'ON':
  81. self._mute = True
  82. elif arg == 'OFF':
  83. self._mute = False
  84. else:
  85. raise RuntimeError('unknown MU arg: %s' % `arg`)
  86. def proc_ZM(self, arg):
  87. if arg == 'ON':
  88. self._zm = True
  89. elif arg == 'OFF':
  90. self._zm = False
  91. else:
  92. raise RuntimeError('unknown ZM arg: %s' % `arg`)
  93. def proc_MV(self, arg):
  94. if arg[:4] == 'MAX ':
  95. self._volmax = self._parsevolarg(arg[4:])
  96. else:
  97. self._vol = self._parsevolarg(arg)
  98. def proc_MS(self, arg):
  99. self._ms = arg
  100. def proc_PS(self, arg):
  101. if arg == 'FRONT A':
  102. self._speakera = True
  103. self._speakerb = False
  104. else:
  105. raise RuntimeError('unknown PS arg: %s' % `arg`)
  106. def proc_Z2(self, arg):
  107. if arg == 'MUOFF':
  108. self._z2mute = False
  109. else:
  110. raise RuntimeError('unknown Z2 arg: %s' % `arg`)
  111. def _sendcmd(self, cmd, args):
  112. cmd = '%s%s\r' % (cmd, args)
  113. print 'scmd:', `cmd`
  114. self._ser.write(cmd)
  115. self._ser.flush()
  116. def _readcmd(self, timo=None):
  117. '''If timo == 0, and the first read returns the empty string,
  118. it will return an empty command, otherwise returns a
  119. command.'''
  120. cmd = ''
  121. #while True:
  122. if timo is not None:
  123. oldtimo = self._ser.timeout
  124. self._ser.timeout = timo
  125. for i in xrange(30):
  126. c = self._ser.read()
  127. if (timo == 0 or timo is None) and c == '':
  128. break
  129. #print 'r:', `c`, `str(c)`
  130. if c == '\r':
  131. break
  132. cmd += c
  133. else:
  134. raise RuntimeError('overrun!')
  135. if timo is not None:
  136. self._ser.timeout = oldtimo
  137. print 'rc:', `cmd`
  138. return cmd
  139. def process_events(self, till=None):
  140. '''Process events until the till command is received, otherwise
  141. process a single event.'''
  142. assert till is None or len(till) == 2
  143. print 'till:', `till`
  144. while True:
  145. event = self._readcmd()
  146. if len(event) >= 2:
  147. fun = getattr(self, 'proc_%s' % event[:2])
  148. fun(event[2:])
  149. if till is None or event[:2] == till:
  150. return event
  151. def update(self):
  152. '''Update the status of the AVR.'''
  153. self._sendcmd('PW', '?')
  154. self._sendcmd('MV', '?')
  155. self.process_events(till='MV') # first vol
  156. self.process_events(till='MV') # second max vol
  157. class TestDenon(unittest.TestCase):
  158. TEST_DEV = '/dev/tty.usbserial-FTC8DHBJ'
  159. # comment out to make it easy to restore skip
  160. @unittest.skip('perf')
  161. def test_comms(self):
  162. avr = DenonAVR(self.TEST_DEV)
  163. self.assertIsNone(avr.power)
  164. avr.update()
  165. self.assertIsNotNone(avr.power)
  166. self.assertIsNotNone(avr.vol)
  167. avr.power = False
  168. time.sleep(1)
  169. avr.power = True
  170. self.assertTrue(avr.power)
  171. print 'foostart'
  172. time.sleep(1)
  173. avr.update()
  174. time.sleep(1)
  175. avr.vol = 0
  176. self.assertEqual(avr.vol, 0)
  177. time.sleep(1)
  178. avr.vol = 5
  179. avr.update()
  180. self.assertEqual(avr.vol, 5)
  181. avr.vol = 50
  182. avr.update()
  183. self.assertEqual(avr.vol, 50)
  184. avr.power = False
  185. self.assertFalse(avr.power)
  186. self.assertIsNotNone(avr.volmax)
  187. class TestStaticMethods(unittest.TestCase):
  188. def test_makevolarg(self):
  189. self.assertRaises(ValueError, DenonAVR._makevolarg, -1)
  190. self.assertRaises(ValueError, DenonAVR._makevolarg, 3874)
  191. self.assertRaises(ValueError, DenonAVR._makevolarg, 100)
  192. self.assertEqual(DenonAVR._makevolarg(0), '99')
  193. self.assertEqual(DenonAVR._makevolarg(1), '00')
  194. self.assertEqual(DenonAVR._makevolarg(99), '98')
  195. def test_parsevolarg(self):
  196. self.assertEqual(DenonAVR._parsevolarg('99'), 0)
  197. self.assertEqual(DenonAVR._parsevolarg('00'), 1)
  198. self.assertEqual(DenonAVR._parsevolarg('98'), 99)
  199. self.assertRaises(ValueError, DenonAVR._parsevolarg, '-1')
  200. class TestMethods(unittest.TestCase):
  201. @mock.patch('serial.serial_for_url')
  202. def setUp(self, sfu):
  203. self.avr = DenonAVR('null')
  204. def test_proc_events(self):
  205. avr = self.avr
  206. avr._ser.read.side_effect = 'PWON\r'
  207. avr.process_events()
  208. self.assertTrue(avr._ser.read.called)
  209. avr._ser.read.reset()
  210. avr._ser.read.side_effect = 'MUON\r' + 'PWON\r'
  211. avr.process_events(till='PW')
  212. avr._ser.read.assert_has_calls([ mock.call(), mock.call() ])
  213. @mock.patch('denon.DenonAVR._sendcmd')
  214. @mock.patch('denon.DenonAVR.process_events')
  215. @mock.patch('time.sleep')
  216. @mock.patch('denon.DenonAVR.update')
  217. def test_proc_PW(self, mupdate, msleep, mpevents, msendcmd):
  218. avr = self.avr
  219. avr.proc_PW('STANDBY')
  220. self.assertEqual(avr.power, False)
  221. avr.proc_PW('ON')
  222. self.assertEqual(avr.power, True)
  223. self.assertRaises(RuntimeError, avr.proc_PW, 'foobar')
  224. avr.power = False
  225. msendcmd.assert_any_call('PW', 'STANDBY')
  226. def test_proc_MU(self):
  227. avr = self.avr
  228. avr.proc_MU('ON')
  229. self.assertEqual(avr._mute, True)
  230. avr.proc_MU('OFF')
  231. self.assertEqual(avr._mute, False)
  232. self.assertRaises(RuntimeError, avr.proc_MU, 'foobar')
  233. def test_proc_PS(self):
  234. avr = self.avr
  235. avr.proc_PS('FRONT A')
  236. self.assertEqual(avr._speakera, True)
  237. self.assertEqual(avr._speakerb, False)
  238. self.assertRaises(RuntimeError, avr.proc_PS, 'foobar')
  239. def test_proc_Z2(self):
  240. avr = self.avr
  241. avr.proc_Z2('MUOFF')
  242. self.assertEqual(avr._z2mute, False)
  243. self.assertRaises(RuntimeError, avr.proc_Z2, 'foobar')
  244. def test_proc_MS(self):
  245. avr = self.avr
  246. avr.proc_MS('STEREO')
  247. self.assertEqual(avr.ms, 'STEREO')
  248. def test_proc_ZM(self):
  249. avr = self.avr
  250. avr.proc_ZM('ON')
  251. self.assertEqual(avr._zm, True)
  252. avr.proc_ZM('OFF')
  253. self.assertEqual(avr._zm, False)
  254. self.assertRaises(RuntimeError, avr.proc_ZM, 'foobar')
  255. @mock.patch('denon.DenonAVR.process_events')
  256. def test_proc_MV(self, pe):
  257. avr = self.avr
  258. avr.proc_MV('MAX 80')
  259. self.assertEqual(avr._volmax, 81)
  260. avr.proc_MV('99')
  261. self.assertEqual(avr._vol, 0)
  262. avr.vol = 0
  263. # we don't call this as we don't get a response
  264. pe.assert_not_called()
  265. self.assertRaises(ValueError, setattr, avr, 'vol', 82)
  266. def test_readcmd(self):
  267. avr = self.avr
  268. # Test no pending cmd and that timeout is set
  269. timov = .5
  270. timovcur = [ timov ]
  271. def curtimo(*args):
  272. assert len(args) in (0, 1)
  273. if len(args):
  274. timovcur[0] = args[0]
  275. else:
  276. return timovcur[0]
  277. timo = mock.PropertyMock(side_effect=curtimo)
  278. type(avr._ser).timeout = timo
  279. avr._ser.read.side_effect = [ '' ]
  280. r = avr._readcmd(timo=0)
  281. # original value restored
  282. self.assertEqual(avr._ser.timeout, timov)
  283. # that the timeout was set
  284. timo.assert_any_call(0)
  285. # and it returned an empty command
  286. self.assertEqual(r, '')
  287. # that it got returned the the old value
  288. self.assertEqual(avr._ser.timeout, timov)
  289. avr._ser.read.side_effect = 'MUON\r'
  290. r = avr._readcmd(timo=1)
  291. self.assertEqual(r, 'MUON')
  292. self.assertEqual(avr._ser.timeout, timov)