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.
 
 

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