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.
 
 

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