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.
 
 

477 lines
10 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. from twisted.internet.defer import inlineCallbacks, Deferred, returnValue
  32. from twisted.protocols import basic
  33. from twisted.test import proto_helpers
  34. from twisted.trial import unittest
  35. import mock
  36. import time
  37. import twisted.internet.serialport
  38. __all__ = [ 'DenonAVR' ]
  39. class DenonAVR(object,basic.LineReceiver):
  40. delimiter = '\r'
  41. timeOut = 1
  42. def __init__(self, serdev):
  43. '''Specify the serial device connected to the Denon AVR.'''
  44. self._ser = twisted.internet.serialport.SerialPort(self, serdev, None, baudrate=9600)
  45. self._cmdswaiting = {}
  46. self._power = None
  47. self._vol = None
  48. self._volmax = None
  49. self._speakera = None
  50. self._speakerb = None
  51. self._z2mute = None
  52. self._zm = None
  53. self._ms = None
  54. def _magic(cmd, attrname, settrans, args, doc):
  55. def getter(self):
  56. return getattr(self, attrname)
  57. def setter(self, arg):
  58. arg = settrans(arg)
  59. if arg != getattr(self, attrname):
  60. self._sendcmd(cmd, args[arg])
  61. return property(getter, setter, doc=doc)
  62. @property
  63. def ms(self):
  64. 'Surround mode'
  65. return self._ms
  66. power = _magic('PW', '_power', bool, { True: 'ON', False: 'STANDBY' }, 'Power status, True if on')
  67. mute = _magic('MU', '_mute', bool, { True: 'ON', False: 'OFF' }, 'Mute speakers, True speakers are muted (no sound)')
  68. z2mute = _magic('Z2MU', '_z2mute', bool, { True: 'ON', False: 'OFF' }, 'Mute Zone 2 speakers, True speakers are muted (no sound)')
  69. @staticmethod
  70. def _makevolarg(arg):
  71. arg = int(arg)
  72. if arg < 0 or arg > 99:
  73. raise ValueError('Volume out of range.')
  74. arg -= 1
  75. arg %= 100
  76. return '%02d' % arg
  77. @staticmethod
  78. def _parsevolarg(arg):
  79. arg = int(arg)
  80. if arg < 0 or arg > 99:
  81. raise ValueError('Volume out of range.')
  82. arg += 1
  83. arg %= 100
  84. return arg
  85. @property
  86. def vol(self):
  87. 'Volumn, range 0 through 99'
  88. return self._vol
  89. @vol.setter
  90. def vol(self, arg):
  91. if arg == self._vol:
  92. return
  93. if self._volmax is not None and arg > self._volmax:
  94. raise ValueError('volume %d, exceeds max: %d' % (arg,
  95. self._volmax))
  96. arg = self._makevolarg(arg)
  97. self._sendcmd('MV', arg)
  98. @property
  99. def volmax(self):
  100. 'Maximum volume supported.'
  101. return self._volmax
  102. def proc_PW(self, arg):
  103. if arg == 'STANDBY':
  104. self._power = False
  105. elif arg == 'ON':
  106. self._power = True
  107. else:
  108. raise RuntimeError('unknown PW arg: %s' % `arg`)
  109. def proc_MU(self, arg):
  110. if arg == 'ON':
  111. self._mute = True
  112. elif arg == 'OFF':
  113. self._mute = False
  114. else:
  115. raise RuntimeError('unknown MU arg: %s' % `arg`)
  116. def proc_ZM(self, arg):
  117. if arg == 'ON':
  118. self._zm = True
  119. elif arg == 'OFF':
  120. self._zm = False
  121. else:
  122. raise RuntimeError('unknown ZM arg: %s' % `arg`)
  123. def proc_MV(self, arg):
  124. if arg[:4] == 'MAX ':
  125. self._volmax = self._parsevolarg(arg[4:])
  126. else:
  127. self._vol = self._parsevolarg(arg)
  128. def proc_MS(self, arg):
  129. self._ms = arg
  130. def proc_PS(self, arg):
  131. if arg == 'FRONT A':
  132. self._speakera = True
  133. self._speakerb = False
  134. else:
  135. raise RuntimeError('unknown PS arg: %s' % `arg`)
  136. def proc_Z2(self, arg):
  137. if arg == 'MUOFF':
  138. self._z2mute = False
  139. else:
  140. raise RuntimeError('unknown Z2 arg: %s' % `arg`)
  141. def _sendcmd(self, cmd, args):
  142. cmd = '%s%s' % (cmd, args)
  143. #print 'sendcmd:', `cmd`
  144. self.sendLine(cmd)
  145. def lineReceived(self, event):
  146. '''Process a line from the AVR.'''
  147. #print 'lR:', `event`
  148. if len(event) >= 2:
  149. fun = getattr(self, 'proc_%s' % event[:2])
  150. fun(event[2:])
  151. for d in self._cmdswaiting.pop(event[:2], []):
  152. d.callback(event)
  153. def _waitfor(self, resp):
  154. d = Deferred()
  155. cmd = resp[:2]
  156. self._cmdswaiting.setdefault(cmd, []).append(d)
  157. if len(resp) > 2:
  158. @inlineCallbacks
  159. def extraresp(d=d):
  160. while True:
  161. r = yield d
  162. if r.startswith(resp):
  163. returnValue(r)
  164. d = self._waitfor(cmd)
  165. d = extraresp()
  166. return d
  167. @inlineCallbacks
  168. def update(self):
  169. '''Update the status of the AVR. This ensures that the
  170. state of the object matches the amp.'''
  171. d = self._waitfor('PW')
  172. self._sendcmd('PW', '?')
  173. d = yield d
  174. d = self._waitfor('MVMAX')
  175. self._sendcmd('MV', '?')
  176. d = yield d
  177. class TestDenon(unittest.TestCase):
  178. TEST_DEV = '/dev/tty.usbserial-FTC8DHBJ'
  179. def test_comms(self): # pragma: no cover
  180. # comment out to make it easy to restore skip
  181. self.skipTest('perf')
  182. avr = DenonAVR(self.TEST_DEV)
  183. self.assertIsNone(avr.power)
  184. avr.update()
  185. self.assertIsNotNone(avr.power)
  186. self.assertIsNotNone(avr.vol)
  187. avr.power = False
  188. time.sleep(1)
  189. avr.power = True
  190. self.assertTrue(avr.power)
  191. print 'foostart'
  192. time.sleep(1)
  193. avr.update()
  194. time.sleep(1)
  195. avr.vol = 0
  196. self.assertEqual(avr.vol, 0)
  197. time.sleep(1)
  198. avr.vol = 5
  199. avr.update()
  200. self.assertEqual(avr.vol, 5)
  201. avr.vol = 50
  202. avr.update()
  203. self.assertEqual(avr.vol, 50)
  204. avr.power = False
  205. self.assertFalse(avr.power)
  206. self.assertIsNotNone(avr.volmax)
  207. class TestStaticMethods(unittest.TestCase):
  208. def test_makevolarg(self):
  209. self.assertRaises(ValueError, DenonAVR._makevolarg, -1)
  210. self.assertRaises(ValueError, DenonAVR._makevolarg, 3874)
  211. self.assertRaises(ValueError, DenonAVR._makevolarg, 100)
  212. self.assertEqual(DenonAVR._makevolarg(0), '99')
  213. self.assertEqual(DenonAVR._makevolarg(1), '00')
  214. self.assertEqual(DenonAVR._makevolarg(99), '98')
  215. def test_parsevolarg(self):
  216. self.assertEqual(DenonAVR._parsevolarg('99'), 0)
  217. self.assertEqual(DenonAVR._parsevolarg('00'), 1)
  218. self.assertEqual(DenonAVR._parsevolarg('98'), 99)
  219. self.assertRaises(ValueError, DenonAVR._parsevolarg, '-1')
  220. class TestMethods(unittest.TestCase):
  221. @mock.patch('twisted.internet.serialport.SerialPort')
  222. def setUp(self, sfu):
  223. self.avr = DenonAVR('null')
  224. self.tr = proto_helpers.StringTransport()
  225. self.avr.makeConnection(self.tr)
  226. @staticmethod
  227. def getTimeout():
  228. return .3
  229. @inlineCallbacks
  230. def test_update(self):
  231. avr = self.avr
  232. d = avr.update()
  233. self.assertEqual(self.tr.value(), 'PW?\r')
  234. avr.dataReceived('PWSTANDBY\r')
  235. avr.dataReceived('MV51\rMVMAX 80\r')
  236. d = yield d
  237. self.assertEqual(self.tr.value(), 'PW?\rMV?\r')
  238. self.assertEqual(avr.power, False)
  239. self.assertIsNone(d)
  240. self.tr.clear()
  241. d = avr.update()
  242. self.assertEqual(self.tr.value(), 'PW?\r')
  243. avr.dataReceived('PWON\rZMON\rMUOFF\rZ2MUOFF\rMUOFF\rPSFRONT A\r')
  244. avr.dataReceived('MSDIRECT\rMSDIRECT\rMSDIRECT\rMV51\rMVMAX 80\r')
  245. d = yield d
  246. self.assertEqual(self.tr.value(), 'PW?\rMV?\r')
  247. self.assertEqual(avr.power, True)
  248. self.assertIsNone(d)
  249. @inlineCallbacks
  250. def test_waitfor(self):
  251. avr = self.avr
  252. avr.proc_AB = lambda arg: None
  253. d = avr._waitfor('AB123')
  254. # make sure that matching, but different response doesn't trigger
  255. avr.dataReceived('ABABC\r')
  256. self.assertFalse(d.called)
  257. # make sure that it triggers
  258. avr.dataReceived('AB123\r')
  259. self.assertTrue(d.called)
  260. d = yield d
  261. # and we get correct response
  262. self.assertEqual(d, 'AB123')
  263. @inlineCallbacks
  264. def test_vol(self):
  265. avr = self.avr
  266. d = avr.update()
  267. self.assertEqual(self.tr.value(), 'PW?\r')
  268. avr.dataReceived('PWON\rZMON\rMUOFF\rZ2MUOFF\rMUOFF\rPSFRONT A\r')
  269. avr.dataReceived('MSDIRECT\rMSDIRECT\rMSDIRECT\rMV51\rMVMAX 80\r')
  270. d = yield d
  271. self.tr.clear()
  272. avr.vol = 20
  273. self.assertEqual(self.tr.value(), 'MV19\r')
  274. def test_proc_events(self):
  275. avr = self.avr
  276. avr.dataReceived('PWON\r')
  277. self.assertEqual(avr.power, True)
  278. avr.dataReceived('MUON\r' + 'PWON\r')
  279. self.assertEqual(avr.mute, True)
  280. self.assertEqual(avr.power, True)
  281. avr.dataReceived('PWSTANDBY\r')
  282. self.assertEqual(avr.power, False)
  283. @mock.patch('yadenon.DenonAVR.sendLine')
  284. def test_proc_PW(self, sendline):
  285. avr = self.avr
  286. avr.proc_PW('STANDBY')
  287. self.assertEqual(avr.power, False)
  288. avr.proc_PW('ON')
  289. self.assertEqual(avr.power, True)
  290. self.assertRaises(RuntimeError, avr.proc_PW, 'foobar')
  291. avr.power = False
  292. sendline.assert_any_call('PWSTANDBY')
  293. def test_proc_MU(self):
  294. avr = self.avr
  295. avr.proc_MU('ON')
  296. self.assertEqual(avr.mute, True)
  297. avr.proc_MU('OFF')
  298. self.assertEqual(avr.mute, False)
  299. self.assertRaises(RuntimeError, avr.proc_MU, 'foobar')
  300. def test_proc_PS(self):
  301. avr = self.avr
  302. avr.proc_PS('FRONT A')
  303. self.assertEqual(avr._speakera, True)
  304. self.assertEqual(avr._speakerb, False)
  305. self.assertRaises(RuntimeError, avr.proc_PS, 'foobar')
  306. def test_proc_Z2(self):
  307. avr = self.avr
  308. avr.proc_Z2('MUOFF')
  309. self.assertEqual(avr.z2mute, False)
  310. self.assertRaises(RuntimeError, avr.proc_Z2, 'foobar')
  311. def test_proc_MS(self):
  312. avr = self.avr
  313. avr.proc_MS('STEREO')
  314. self.assertEqual(avr.ms, 'STEREO')
  315. def test_proc_ZM(self):
  316. avr = self.avr
  317. avr.proc_ZM('ON')
  318. self.assertEqual(avr._zm, True)
  319. avr.proc_ZM('OFF')
  320. self.assertEqual(avr._zm, False)
  321. self.assertRaises(RuntimeError, avr.proc_ZM, 'foobar')
  322. def test_proc_MV(self):
  323. avr = self.avr
  324. avr.proc_MV('MAX 80')
  325. self.assertEqual(avr.volmax, 81)
  326. avr.proc_MV('99')
  327. self.assertEqual(avr.vol, 0)
  328. avr.vol = 0
  329. self.assertRaises(ValueError, setattr, avr, 'vol', 82)