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.
 
 

444 lines
9.9 KiB

  1. #!/usr/bin/env python
  2. __author__ = 'John-Mark Gurney'
  3. __copyright__ = 'Copyright 2017 John-Mark Gurney. All rights reserved.'
  4. __license__ = '2-clause BSD license'
  5. # Copyright 2017, John-Mark Gurney
  6. # All rights reserved.
  7. #
  8. # Redistribution and use in source and binary forms, with or without
  9. # modification, are permitted provided that the following conditions are met:
  10. #
  11. # 1. Redistributions of source code must retain the above copyright notice, this
  12. # list of conditions and the following disclaimer.
  13. # 2. Redistributions in binary form must reproduce the above copyright notice,
  14. # this list of conditions and the following disclaimer in the documentation
  15. # and/or other materials provided with the distribution.
  16. #
  17. # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
  18. # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
  19. # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
  20. # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
  21. # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
  22. # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
  23. # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
  24. # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  25. # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  26. # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  27. #
  28. # The views and conclusions contained in the software and documentation are those
  29. # of the authors and should not be interpreted as representing official policies,
  30. # either expressed or implied, of the Project.
  31. import mock
  32. import serial
  33. import unittest
  34. import threading
  35. import time
  36. __all__ = [ 'DenonAVR' ]
  37. class DenonAVR(object):
  38. def __init__(self, serdev):
  39. '''Specify the serial device connected to the Denon AVR.'''
  40. self._ser = serial.serial_for_url(serdev, baudrate=9600,
  41. timeout=.5)
  42. self._power = None
  43. self._vol = None
  44. self._volmax = None
  45. self._speakera = None
  46. self._speakerb = None
  47. self._z2mute = None
  48. self._zm = None
  49. self._ms = None
  50. @property
  51. def ms(self):
  52. 'Surround mode'
  53. return self._ms
  54. @property
  55. def power(self):
  56. 'Power status, True if on'
  57. return self._power
  58. @power.setter
  59. def power(self, arg):
  60. arg = bool(arg)
  61. if arg != self._power:
  62. args = { True: 'ON', False: 'STANDBY' }
  63. self._sendcmd('PW', args[arg])
  64. self.process_events(till='PW')
  65. time.sleep(1)
  66. self.update()
  67. @staticmethod
  68. def _makevolarg(arg):
  69. arg = int(arg)
  70. if arg < 0 or arg > 99:
  71. raise ValueError('Volume out of range.')
  72. arg -= 1
  73. arg %= 100
  74. return '%02d' % arg
  75. @staticmethod
  76. def _parsevolarg(arg):
  77. arg = int(arg)
  78. if arg < 0 or arg > 99:
  79. raise ValueError('Volume out of range.')
  80. arg += 1
  81. arg %= 100
  82. return arg
  83. @property
  84. def vol(self):
  85. 'Volumn, range 0 through 99'
  86. return self._vol
  87. @vol.setter
  88. def vol(self, arg):
  89. if arg == self._vol:
  90. return
  91. if self._volmax is not None and arg > self._volmax:
  92. raise ValueError('volume %d, exceeds max: %d' % (arg,
  93. self._volmax))
  94. arg = self._makevolarg(arg)
  95. time.sleep(1)
  96. self._sendcmd('MV', arg)
  97. self.process_events(till='MV')
  98. self.process_events(till='MV')
  99. @property
  100. def volmax(self):
  101. 'Maximum volume supported.'
  102. return self._volmax
  103. def proc_PW(self, arg):
  104. if arg == 'STANDBY':
  105. self._power = False
  106. elif arg == 'ON':
  107. self._power = True
  108. else:
  109. raise RuntimeError('unknown PW arg: %s' % `arg`)
  110. def proc_MU(self, arg):
  111. if arg == 'ON':
  112. self._mute = True
  113. elif arg == 'OFF':
  114. self._mute = False
  115. else:
  116. raise RuntimeError('unknown MU arg: %s' % `arg`)
  117. def proc_ZM(self, arg):
  118. if arg == 'ON':
  119. self._zm = True
  120. elif arg == 'OFF':
  121. self._zm = False
  122. else:
  123. raise RuntimeError('unknown ZM arg: %s' % `arg`)
  124. def proc_MV(self, arg):
  125. if arg[:4] == 'MAX ':
  126. self._volmax = self._parsevolarg(arg[4:])
  127. else:
  128. self._vol = self._parsevolarg(arg)
  129. def proc_MS(self, arg):
  130. self._ms = arg
  131. def proc_PS(self, arg):
  132. if arg == 'FRONT A':
  133. self._speakera = True
  134. self._speakerb = False
  135. else:
  136. raise RuntimeError('unknown PS arg: %s' % `arg`)
  137. def proc_Z2(self, arg):
  138. if arg == 'MUOFF':
  139. self._z2mute = False
  140. else:
  141. raise RuntimeError('unknown Z2 arg: %s' % `arg`)
  142. def _sendcmd(self, cmd, args):
  143. cmd = '%s%s\r' % (cmd, args)
  144. print 'scmd:', `cmd`
  145. self._ser.write(cmd)
  146. self._ser.flush()
  147. def _readcmd(self, timo=None):
  148. '''If timo == 0, and the first read returns the empty string,
  149. it will return an empty command, otherwise returns a
  150. command.'''
  151. cmd = ''
  152. #while True:
  153. if timo is not None:
  154. oldtimo = self._ser.timeout
  155. self._ser.timeout = timo
  156. for i in xrange(30):
  157. c = self._ser.read()
  158. if (timo == 0 or timo is None) and c == '':
  159. break
  160. #print 'r:', `c`, `str(c)`
  161. if c == '\r':
  162. break
  163. cmd += c
  164. else:
  165. raise RuntimeError('overrun!')
  166. if timo is not None:
  167. self._ser.timeout = oldtimo
  168. print 'rc:', `cmd`
  169. return cmd
  170. def process_events(self, till=None):
  171. '''Process events until the till command is received, otherwise
  172. process a single event.'''
  173. assert till is None or len(till) == 2
  174. print 'till:', `till`
  175. while True:
  176. event = self._readcmd()
  177. if len(event) >= 2:
  178. fun = getattr(self, 'proc_%s' % event[:2])
  179. fun(event[2:])
  180. if till is None or event[:2] == till:
  181. return event
  182. def update(self):
  183. '''Update the status of the AVR. This ensures that the
  184. state of the object matches the amp.'''
  185. self._sendcmd('PW', '?')
  186. self._sendcmd('MV', '?')
  187. self.process_events(till='MV') # first vol
  188. self.process_events(till='MV') # second max vol
  189. class TestDenon(unittest.TestCase):
  190. TEST_DEV = '/dev/tty.usbserial-FTC8DHBJ'
  191. # comment out to make it easy to restore skip
  192. @unittest.skip('perf')
  193. def test_comms(self):
  194. avr = DenonAVR(self.TEST_DEV)
  195. self.assertIsNone(avr.power)
  196. avr.update()
  197. self.assertIsNotNone(avr.power)
  198. self.assertIsNotNone(avr.vol)
  199. avr.power = False
  200. time.sleep(1)
  201. avr.power = True
  202. self.assertTrue(avr.power)
  203. print 'foostart'
  204. time.sleep(1)
  205. avr.update()
  206. time.sleep(1)
  207. avr.vol = 0
  208. self.assertEqual(avr.vol, 0)
  209. time.sleep(1)
  210. avr.vol = 5
  211. avr.update()
  212. self.assertEqual(avr.vol, 5)
  213. avr.vol = 50
  214. avr.update()
  215. self.assertEqual(avr.vol, 50)
  216. avr.power = False
  217. self.assertFalse(avr.power)
  218. self.assertIsNotNone(avr.volmax)
  219. class TestStaticMethods(unittest.TestCase):
  220. def test_makevolarg(self):
  221. self.assertRaises(ValueError, DenonAVR._makevolarg, -1)
  222. self.assertRaises(ValueError, DenonAVR._makevolarg, 3874)
  223. self.assertRaises(ValueError, DenonAVR._makevolarg, 100)
  224. self.assertEqual(DenonAVR._makevolarg(0), '99')
  225. self.assertEqual(DenonAVR._makevolarg(1), '00')
  226. self.assertEqual(DenonAVR._makevolarg(99), '98')
  227. def test_parsevolarg(self):
  228. self.assertEqual(DenonAVR._parsevolarg('99'), 0)
  229. self.assertEqual(DenonAVR._parsevolarg('00'), 1)
  230. self.assertEqual(DenonAVR._parsevolarg('98'), 99)
  231. self.assertRaises(ValueError, DenonAVR._parsevolarg, '-1')
  232. class TestMethods(unittest.TestCase):
  233. @mock.patch('serial.serial_for_url')
  234. def setUp(self, sfu):
  235. self.avr = DenonAVR('null')
  236. def test_proc_events(self):
  237. avr = self.avr
  238. avr._ser.read.side_effect = 'PWON\r'
  239. avr.process_events()
  240. self.assertTrue(avr._ser.read.called)
  241. avr._ser.read.reset()
  242. avr._ser.read.side_effect = 'MUON\r' + 'PWON\r'
  243. avr.process_events(till='PW')
  244. avr._ser.read.assert_has_calls([ mock.call(), mock.call() ])
  245. @mock.patch('denon.DenonAVR._sendcmd')
  246. @mock.patch('denon.DenonAVR.process_events')
  247. @mock.patch('time.sleep')
  248. @mock.patch('denon.DenonAVR.update')
  249. def test_proc_PW(self, mupdate, msleep, mpevents, msendcmd):
  250. avr = self.avr
  251. avr.proc_PW('STANDBY')
  252. self.assertEqual(avr.power, False)
  253. avr.proc_PW('ON')
  254. self.assertEqual(avr.power, True)
  255. self.assertRaises(RuntimeError, avr.proc_PW, 'foobar')
  256. avr.power = False
  257. msendcmd.assert_any_call('PW', 'STANDBY')
  258. def test_proc_MU(self):
  259. avr = self.avr
  260. avr.proc_MU('ON')
  261. self.assertEqual(avr._mute, True)
  262. avr.proc_MU('OFF')
  263. self.assertEqual(avr._mute, False)
  264. self.assertRaises(RuntimeError, avr.proc_MU, 'foobar')
  265. def test_proc_PS(self):
  266. avr = self.avr
  267. avr.proc_PS('FRONT A')
  268. self.assertEqual(avr._speakera, True)
  269. self.assertEqual(avr._speakerb, False)
  270. self.assertRaises(RuntimeError, avr.proc_PS, 'foobar')
  271. def test_proc_Z2(self):
  272. avr = self.avr
  273. avr.proc_Z2('MUOFF')
  274. self.assertEqual(avr._z2mute, False)
  275. self.assertRaises(RuntimeError, avr.proc_Z2, 'foobar')
  276. def test_proc_MS(self):
  277. avr = self.avr
  278. avr.proc_MS('STEREO')
  279. self.assertEqual(avr.ms, 'STEREO')
  280. def test_proc_ZM(self):
  281. avr = self.avr
  282. avr.proc_ZM('ON')
  283. self.assertEqual(avr._zm, True)
  284. avr.proc_ZM('OFF')
  285. self.assertEqual(avr._zm, False)
  286. self.assertRaises(RuntimeError, avr.proc_ZM, 'foobar')
  287. @mock.patch('denon.DenonAVR.process_events')
  288. def test_proc_MV(self, pe):
  289. avr = self.avr
  290. avr.proc_MV('MAX 80')
  291. self.assertEqual(avr._volmax, 81)
  292. avr.proc_MV('99')
  293. self.assertEqual(avr._vol, 0)
  294. avr.vol = 0
  295. # we don't call this as we don't get a response
  296. pe.assert_not_called()
  297. self.assertRaises(ValueError, setattr, avr, 'vol', 82)
  298. def test_readcmd(self):
  299. avr = self.avr
  300. # Test no pending cmd and that timeout is set
  301. timov = .5
  302. timovcur = [ timov ]
  303. def curtimo(*args):
  304. assert len(args) in (0, 1)
  305. if len(args):
  306. timovcur[0] = args[0]
  307. else:
  308. return timovcur[0]
  309. timo = mock.PropertyMock(side_effect=curtimo)
  310. type(avr._ser).timeout = timo
  311. avr._ser.read.side_effect = [ '' ]
  312. r = avr._readcmd(timo=0)
  313. # original value restored
  314. self.assertEqual(avr._ser.timeout, timov)
  315. # that the timeout was set
  316. timo.assert_any_call(0)
  317. # and it returned an empty command
  318. self.assertEqual(r, '')
  319. # that it got returned the the old value
  320. self.assertEqual(avr._ser.timeout, timov)
  321. avr._ser.read.side_effect = 'MUON\r'
  322. r = avr._readcmd(timo=1)
  323. self.assertEqual(r, 'MUON')
  324. self.assertEqual(avr._ser.timeout, timov)