1#!/usr/bin/env python
2#
3# Copyright (c) 2016, The OpenThread Authors.
4# All rights reserved.
5#
6# Redistribution and use in source and binary forms, with or without
7# modification, are permitted provided that the following conditions are met:
8# 1. Redistributions of source code must retain the above copyright
9#    notice, this list of conditions and the following disclaimer.
10# 2. Redistributions in binary form must reproduce the above copyright
11#    notice, this list of conditions and the following disclaimer in the
12#    documentation and/or other materials provided with the distribution.
13# 3. Neither the name of the copyright holder nor the
14#    names of its contributors may be used to endorse or promote products
15#    derived from this software without specific prior written permission.
16#
17# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
21# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27# POSSIBILITY OF SUCH DAMAGE.
28#
29
30import logging
31import os
32import re
33import telnetlib
34import time
35
36try:
37    # python 2
38    from urllib2 import HTTPPasswordMgrWithDefaultRealm, HTTPBasicAuthHandler, build_opener
39except ImportError:
40    # python 3
41    from urllib.request import HTTPPasswordMgrWithDefaultRealm, HTTPBasicAuthHandler, build_opener
42
43logger = logging.getLogger(__name__)
44
45try:
46    from pysnmp.hlapi import SnmpEngine, CommunityData, UdpTransportTarget, ContextData, getCmd, setCmd, ObjectType, ObjectIdentity, Integer32
47except ImportError:
48    logger.warning('PySNMP module is not installed. Install if EATON_PDU_CONTROLLER is used')
49
50
51class PduController(object):
52
53    def open(self, **params):
54        """Open PDU controller connection"""
55        raise NotImplementedError
56
57    def reboot(self, **params):
58        """Reboot an outlet or a board passed as params"""
59        raise NotImplementedError
60
61    def close(self):
62        """Close PDU controller connection"""
63        raise NotImplementedError
64
65
66class DummyPduController(PduController):
67    """Dummy implementation which only says that PDU controller is not connected"""
68
69    def open(self, **params):
70        pass
71
72    def reboot(self, **params):
73        logger.info('No PDU controller connected.')
74
75    def close(self):
76        pass
77
78
79class ApcPduController(PduController):
80
81    def __init__(self):
82        self.tn = None
83
84    def __del__(self):
85        self.close()
86
87    def _init(self):
88        """Initialize the telnet connection
89        """
90        self.tn = telnetlib.Telnet(self.ip, self.port)
91        self.tn.read_until('User Name')
92        self.tn.write('apc\r\n')
93        self.tn.read_until('Password')
94        self.tn.write('apc\r\n')
95        self.until_done()
96
97    def open(self, **params):
98        """Open telnet connection
99
100        Args:
101            params (dict), must contain two parameters "ip" - ip address or hostname and "port" - port number
102
103        Example:
104            params = {'port': 23, 'ip': 'localhost'}
105        """
106        logger.info('opening telnet')
107        self.port = params['port']
108        self.ip = params['ip']
109        self.tn = None
110        self._init()
111
112    def close(self):
113        """Close telnet connection"""
114        logger.info('closing telnet')
115        if self.tn:
116            self.tn.close()
117
118    def until_done(self):
119        """Wait until the prompt encountered
120        """
121        self.until(r'^>')
122
123    def until(self, regex):
124        """Wait until the regex encountered
125        """
126        logger.debug('waiting for %s', regex)
127        r = re.compile(regex, re.M)
128        self.tn.expect([r])
129
130    def reboot(self, **params):
131        """Reboot outlet
132
133        Args:
134            params (dict), must contain parameter "outlet" - outlet number
135
136        Example:
137            params = {'outlet': 1}
138        """
139        outlet = params['outlet']
140
141        # main menu
142        self.tn.write('\x1b\r\n')
143        self.until_done()
144        # Device Manager
145        self.tn.write('1\r\n')
146        self.until_done()
147        # Outlet Management
148        self.tn.write('2\r\n')
149        self.until_done()
150        # Outlet Control
151        self.tn.write('1\r\n')
152        self.until_done()
153        # Select outlet
154        self.tn.write('%d\r\n' % outlet)
155        self.until_done()
156        # Control
157        self.tn.write('1\r\n')
158        self.until_done()
159        # off
160        self.tn.write('2\r\n')
161        self.until('to cancel')
162        self.tn.write('YES\r\n')
163        self.until('to continue')
164        self.tn.write('\r\n')
165        self.until_done()
166
167        time.sleep(5)
168        # on
169        self.tn.write('1\r\n')
170        self.until('to cancel')
171        self.tn.write('YES\r\n')
172        self.until('to continue')
173        self.tn.write('\r\n')
174        self.until_done()
175
176
177class NordicBoardPduController(PduController):
178
179    def open(self, **params):
180        pass
181
182    def _pin_reset(self, serial_number):
183        os.system('nrfjprog -f NRF52 --snr {} -p'.format(serial_number))
184
185    def reboot(self, **params):
186        boards_serial_numbers = params['boards_serial_numbers']
187
188        for serial_number in boards_serial_numbers:
189            logger.info('Resetting board with the serial number: %s', serial_number)
190            self._pin_reset(serial_number)
191
192    def close(self):
193        pass
194
195
196class EatonPduController(PduController):
197
198    outlet_oid_cmd_get_state_base = '1.3.6.1.4.1.534.6.6.7.6.6.1.2.0.'
199    outlet_oid_cmd_set_on_base = '1.3.6.1.4.1.534.6.6.7.6.6.1.4.0.'
200    outlet_oid_cmd_set_off_base = '1.3.6.1.4.1.534.6.6.7.6.6.1.3.0.'
201    outlet_oid_cmd_reboot_base = '1.3.6.1.4.1.534.6.6.7.6.6.1.5.0.'
202    outlet_oid_cmd_set_reboot_delay_seconds_base = '1.3.6.1.4.1.534.6.6.7.6.6.1.8.0.'
203
204    PDU_COMMAND_TIMEOUT = 5
205
206    def open(self, **params):
207        missing_fields = ['ip', 'port']
208        missing_fields = [field for field in missing_fields if field not in params.keys()]
209        if missing_fields:
210            raise KeyError('Missing keys in PDU params: {}'.format(missing_fields))
211        self.params = params
212        self.type = 'pdu'
213        self.ip = self.params['ip']
214        self.snmp_agent_port = int(self.params['port'])
215        self._community = 'public'
216        self._snmp_engine = SnmpEngine()
217        self._community_data = CommunityData(self._community, mpModel=0)
218        self._udp_transport_target = UdpTransportTarget((self.ip, self.snmp_agent_port))
219        self._context = ContextData()
220
221    def _outlet_oid_get(self, param, socket):
222        """
223        Translates command to the OID number representing a command for the specific power socket.
224
225        Args:
226            param (str), command string
227            socket (int), socket index
228
229        Return:
230            full OID identifying the SNMP object (str)
231        """
232        parameters = {
233            'get_state': self.outlet_oid_cmd_get_state_base,
234            'set_on': self.outlet_oid_cmd_set_on_base,
235            'set_off': self.outlet_oid_cmd_set_off_base,
236            'set_reboot_delay': self.outlet_oid_cmd_set_reboot_delay_seconds_base,
237            'reboot': self.outlet_oid_cmd_reboot_base
238        }
239
240        return parameters[param.lower()] + str(socket)
241
242    # Performs set command to specific OID with a given value by sending a SNMP Set message.
243    def _oid_set(self, oid, value):
244        """
245        Performs set command to specific OID with a given value by sending a SNMP Set message.
246
247        Args:
248            oid (str): Full OID identifying the object to be read.
249            value (int): Value to be written to the OID as Integer32.
250        """
251        errorIndication, errorStatus, errorIndex, varBinds = next(
252            setCmd(self._snmp_engine, self._community_data, self._udp_transport_target, self._context,
253                   ObjectType(ObjectIdentity(oid), Integer32(value))))
254
255        if errorIndication:
256            msg = 'Found PDU errorIndication: {}'.format(errorIndication)
257            logger.exception(msg)
258            raise RuntimeError(msg)
259        elif errorStatus:
260            msg = 'Found PDU errorStatus: {}'.format(errorStatus)
261            logger.exception(msg)
262            raise RuntimeError(msg)
263
264    def _oid_get(self, oid):
265        """
266        Performs SNMP get command and returns OID value by sending a SNMP Get message.
267
268        Args:
269            oid (str): Full OID identifying the object to be read.
270
271        Return:
272            OID value (int)
273        """
274        errorIndication, errorStatus, errorIndex, varBinds = next(
275            getCmd(self._snmp_engine, self._community_data, self._udp_transport_target, self._context,
276                   ObjectType(ObjectIdentity(oid))))
277
278        if errorIndication:
279            msg = 'Found PDU errorIndication: {}'.format(errorIndication)
280            logger.exception(msg)
281            raise RuntimeError(msg)
282        elif errorStatus:
283            msg = 'Found PDU errorStatus: {}'.format(errorStatus)
284            logger.exception(msg)
285            raise RuntimeError(msg)
286
287        return int(str(varBinds[-1]).partition('= ')[-1])
288
289    def _outlet_value_set(self, cmd, socket, value=1):
290        """
291        Sets outlet parameter value.
292
293        Args:
294            cmd (str): OID base
295            socket (int): socket index (last OID number)
296            value (int): value to be set
297        """
298        oid = self._outlet_oid_get(cmd, socket)
299
300        # Values other than 1 does not make sense with commands other than "set_reboot_delay".
301        if cmd != 'set_reboot_delay':
302            value = 1
303
304        self._oid_set(oid, value)
305
306    def _outlet_value_get(self, cmd, socket):
307        """
308        Read outlet parameter value.
309
310        Args:
311            cmd (str): OID base
312            socket (int): socket index (last OID number)
313
314        Return:
315            parameter value (int)
316        """
317        oid = self._outlet_oid_get(cmd, socket)
318
319        return self._oid_get(oid)
320
321    def validate_state(self, socket, state):
322        return (self._outlet_value_get('get_state', socket) == state)
323
324    def turn_off(self, sockets):
325        """
326        Turns the specified socket off.
327
328        Args:
329            sockets (list(int)): sockets to be turned off
330        """
331        logger.info('Executing turn OFF for: {}'.format(sockets))
332
333        for socket in sockets:
334            self._outlet_value_set('set_off', socket)
335            time.sleep(2)
336
337            timeout = time.time() + self.PDU_COMMAND_TIMEOUT
338            while ((time.time() < timeout) and not self.validate_state(socket, 0)):
339                time.sleep(0.1)
340
341            if self.validate_state(socket, 0):
342                logger.debug('Turned OFF socket {} at {}'.format(socket, self.ip))
343            else:
344                logger.error('Failed to turn OFF socket {} at {}'.format(socket, self.ip))
345
346    def turn_on(self, sockets):
347        """
348        Turns the specified socket on.
349
350        Args:
351            sockets (list(int)): sockets to be turned on
352        """
353
354        logger.info('Executing turn ON for: {}'.format(sockets))
355
356        for socket in sockets:
357            self._outlet_value_set('set_on', socket)
358            time.sleep(2)
359
360            timeout = time.time() + self.PDU_COMMAND_TIMEOUT
361            while ((time.time() < timeout) and not self.validate_state(socket, 1)):
362                time.sleep(0.1)
363
364            if self.validate_state(socket, 1):
365                logger.debug('Turned ON socket {} at {}'.format(socket, self.ip))
366            else:
367                logger.error('Failed to turn ON socket {} at {}'.format(socket, self.ip))
368
369    def close(self):
370        self._community = None
371        self._snmp_engine = None
372        self._community_data = None
373        self._udp_transport_target = None
374        self._context = None
375
376    def reboot(self, **params):
377        """
378        Reboots the sockets specified in the constructor with off and on delays.
379
380        Args:
381            sockets (list(int)): sockets to reboot
382        """
383
384        logger.info('Executing power cycle for: {}'.format(params['sockets']))
385        self.turn_off(params['sockets'])
386        time.sleep(10)
387        self.turn_on(params['sockets'])
388        time.sleep(5)
389
390
391class IpPowerSocketPduController(PduController):
392
393    def open(self, **params):
394        self._base_url = 'http://{}/outs.cgi?out'.format(params['ip'])
395        password_manager = HTTPPasswordMgrWithDefaultRealm()
396        password_manager.add_password(None, self._base_url, params['user'], params['pass'])
397        authentication_handler = HTTPBasicAuthHandler(password_manager)
398        self._opener = build_opener(authentication_handler)
399
400    def reboot(self, **params):
401        logger.info('Executing power cycle')
402        for socket in params['sockets']:
403            self._turn_off(socket)
404            time.sleep(2)
405            self._turn_on(socket)
406            time.sleep(2)
407
408    def _change_state(self, socket, state):
409        self._opener.open('{}{}={}'.format(self._base_url, socket, state))
410
411    def _turn_off(self, socket):
412        self._change_state(socket, 1)
413
414    def _turn_on(self, socket):
415        self._change_state(socket, 0)
416
417    def close(self):
418        self._base_url = None
419        self._opener = None
420
421
422class ManualPduController(PduController):
423
424    def open(self, **kwargs):
425        pass
426
427    def reboot(self, **kwargs):
428        input('Reset all devices and press enter to continue..')
429
430    def close(self):
431        pass
432