В качестве вводных, имеется довольно крупная сеть на базе решения Cisco, эксплуатируемая уже более 10 лет и состоящая из:

  • 1 348 зданий и сооружений;

  • 10 030 разнотипных точек доступа производства, начиная с откровенного старья AIR-LAP1131G, AIR-LAP1041N (около 88% от общего числа), заканчивая вполне себе неплохими CAP1602I, CAP2602I, CAP1702I, AP1832I;

  • WISM1, WISM2, WLC 8540.

За более чем 10 лет эксплуатации кабельные линии стали приходить в негодность в результате различных перепланировок и капремонтов. Большое количество точек доступа и инжекторов питания стало выходить из строя, что вкупе с тенденцией роста потребления трафика привело к постоянным жалобам на качество сервиса.

С учетом сроков и возможностей бюджета решено, что на первом этапе модернизации будут подвергнуты сети в 589 зданиях, решение о дальнейшей модернизации будет принято после анализа последствий текущей модернизации. 

Сформулированы следующие задачи:

  • замена вышедших из строя и устаревших точек доступа новыми и современными;

  • обеспечение покрытия дополнительных зданий и помещений;

  • замена коммутаторов доступа и инжекторов питания на PoE коммутаторы;

  • реконструкция существующих кабельных линий связи и формирование новых от активного оборудования до точек доступа.

С учетом сроков поставки, первое, что мы должны сделать - это заказать точки доступа. Как определить какое количество точек доступа потребуется, правильно - провести радиоразведку при помощи Ekahau. Объехать все 589 объектов с учетом расстояний нереально, в связи с чем принято решение обследовать часть сооружений разных типов планировки и размещения, городские, в крупных населенных пунктах и в небольших селах, результаты экстраполировать на все объекты.

Cказано - сделано, написаны инструкции  для наших выездных инженеров и в кратчайшие сроки обследовано 89 зданий и сооружений, полученные данные немедленно загружены в Qlik и подвергнуты всестороннему анализу. Рассматривалась общая площадь помещений, количество пользователей и текущее количество точек доступа. В результате получили, что для организации сети на 589 объектах потребуется 8014 точек доступа (забегая немного вперед, скажу, что погрешность нашей экстраполяции не превысила 1% :).

Какие точки доступа заказываем?

И так необходимое количество мы вычислили, осталось выбрать какие точки доступа будем закупать. Имея обширный опыт организации беспроводных сетей мы в свое время обратили внимание на достаточно неплохое решение для организации небольших сетей на базе связки Mikrotik cAP AC + CRS328-24P-4S+RM в качестве PoE коммутатора и CapsMan. Перспективы управлять 589 CapsMan нас не сильно смущала, благо есть опыт написания “Мониторилки точек доступа”, Ansible нам также знаком не понаслышке.

Однако наш старый и добрый партнер, компания Cisco, сделала такое предложение, от которого не смог бы отказаться сам дон Корлеоне :).

Итак заказываем у Cisco точки доступа и пока они едут идем дальше …

Начинаем работу

В первую очередь приняли решение, что все точки доступа имеющиеся на объекте демонтируются и заменяются на новые AIR-AP1815I-R, это облегчит логистику и пусконаладку нашим монтажникам. Демонтируемые точки доступа, а их ни много ни мало 4175 штук, решено использовать в дальнейшем в качестве ЗИП, для оставшихся объектов.

Также решили, что для каждого объекта необходим радиопроект в том же Ekahau иначе гарантировать покрытие невозможно, однако для качественного проектирования  необходимы качественные исходные данные. К тому времени Руководство (спасибо ему за это отдельно) определило, что монтажом будет заниматься подрядчик, вот его то и решено было привлечь для сбора исходных данных.

Для обмена информацией тяжелыми данными с подрядчиком предложено свободно распространяемое приложение Nextcloud, для консолидации информации и контроля за ходом работ предложен Google Sheets, теперь поехали…. :)

Несмотря на кажущуюся сложность схема получилась простой и логичной. Подрядчик выезжает на объект и попутно фотографирует планы БТИ на которых указывает помещения в которых появилась потребность в сети или выявлены проблемы в покрытии, результат выкладывает в заранее подготовленную папку в облаке.

В свою очередь мы готовим схему расстановки точек доступа, также кладем в облако.

Пока точки доступа едут, монтажники занимаются протяжкой кабельных линий.

Но самое интересное начинается по мере поставки коммутаторов и точек доступа, за этим мы и собрались написать эту статью, так как на просторах Интернета аналогичных решений мы не нашли и захотелось поделиться реализацией вдруг кому поможет.

И так самое интересное, автоматизация процесса…

Используем строго OpenSource:

  • Graphite + Graphana — мониторинг состояния точек доступа;

  • SQLite — хранение данных;

  • Python и BASH — скрипты;

  • Telegram bot — проверка состояния точек.

Как уже отмечалось перечень объектов и количество точек доступа мы выложили в Google Sheets вот ее итоговый вид:

Все сходства наименований с реальными объектами вымышлены и не имеют ничего общего с реальностью :) 

Первое, что нужно сделать — собрать исходные данные, делаем это bash скриптом для опроса контроллеров WLC по SNMP:

#!/bin/bash 
#snmp OID для Duplex, для id точки показывает скорость подключения к проводу:
oid_speed=".1.3.6.1.4.1.9.9.513.1.2.2.1.11" 
#snmp OID для Ap_Name, для id точки показывает Ap_Name:
oid_name="SNMPv2-SMI::enterprises.9.9.513.1.1.1.1.5" 
#snmp OID для Ap_Ip_Address, для id точки показывает Ap ip Address:
oid_address=".1.3.6.1.4.1.14179.2.2.1.1.19"
#snmp OID для Ap_mac_address, для id точки показывает Ap mac Address: 
oid_mac=".1.3.6.1.4.1.9.9.513.1.1.1.1.2" 
#Указываем snmp v2 community
community="snmp_comunity" 
#Указываем ip адреса контроллеров
address1="10.x.y.z" 
address2="10.x1.y1.z1" 
snmpwalk -v 2c -c  $comunity $address1  $oid_name | awk '{print $1 $4}' | sed 's/SNMPv2-SMI::enterprises.9.9.513.1.1.1.1.5./'$address1'wlc/' | sed 's/"/ /' | sed 's/"//' > /opt/rename/snmp_files/ap_name_index.txt & 
snmpwalk -v 2c -c  $comunity $address1  $oid_speed | awk '{print substr($1, 1, length($1)-2) " " $4}' | sed 's/SNMPv2-SMI::enterprises.9.9.513.1.2.2.1.11./'$address1'wlc/' | uniq  > /opt/rename/snmp_files/ap_speed_index.txt & 
snmpwalk -v 2c -c  $comunity $address1  $oid_address | awk '{print $1 " " $4}' | sed 's/SNMPv2-SMI::enterprises.14179.2.2.1.1.19./'$address1'wlc/'  > /opt/rename/snmp_files/ap_address_index.txt & 
snmpwalk -v 2c -c  $comunity $address1  $oid_mac | awk '{print $1 " " $4"."$5"."$6"."$7"."$8"."$9}' | sed 's/SNMPv2-SMI::enterprises.9.9.513.1.1.1.1.2./'$address1'wlc/'  > /opt/rename/snmp_files/ap_mac_index.txt

В итоге имеем 4 файла, которые пишем в базу SQLite:

import sqlite3, sys
import logging

logging.basicConfig(filename='/opt/rename/logg/snmp_py.log', filemode='w', format='%(name)s - %(levelname)s - %(message)s')

def processfile(filename):
    """
    Результат работы возвращает список из файла.
    """
    contents = []
    cnt = []
    print ('*** Reading ['+filename+'] ...')
    try:
        f = open(filename)
        contents = f.read().splitlines()
        f.close()
    except IOError:
        logging.warning ("Error opening file: ", filename)
        sys.exit(1)
    for line in contents:
        s = line.split(' ')
        if len(s) == 2:
           cnt.append(s)
    return dict(cnt)

def createdick(Aps,Macs,Addresses,Speeds):
	# Создает словарь из списков
    contents = {}
    try:
       for index in Aps.keys():
          contents[index] = [Aps.get(index).lower(),Macs.get(index), Addresses.get(index), Speeds.get(index),index[:12], index[15:]]
    except:
          logging.warning ("Error creating dick: ", index)

    return contents

def create_db():
    #Создает базу данных
    conn = sqlite3.connect("/opt/rename/db/ApsSnmpDatabase.db")  # или :memory: чтобы сохранить в RAM
    cursor = conn.cursor()
    #Создание таблицы
    cursor.execute("""CREATE TABLE IF NOT EXISTS accesspoints
                  (nameap TEXT , 
                   mac TEXT PRIMARY KEY,
                   ip TEXT ,
                   duplex INTEGER,
                   wlc TEXT,
                   snmp_index TEXT)
               """)
    conn.commit()
    cursor.close()
    conn.close()

def insert_data_db(ApSnmpDb):
	# Добавляет данные в таблицу
    conn = sqlite3.connect("/opt/rename/db/ApsSnmpDatabase.db")
    cursor = conn.cursor()
    for line in ApSnmpDb.values():
        data = tuple(line)
        cursor.execute('INSERT INTO accesspoints VALUES(?, ?, ?, ?, ?, ?)', data)
    conn.commit()
    cursor.close()
    conn.close()

def delete_table():
	# Удаляет таблицу
    conn = sqlite3.connect("/opt/rename/db/ApsSnmpDatabase.db")
    cursor = conn.cursor()
    query = "DROP TABLE IF EXISTS accesspoints"
    cursor.execute(query)
    conn.commit()
    cursor.close()
    conn.close()

def main():
    ap_name_index = '/opt/rename/snmp_files/ap_name_index.txt'
    ap_speed_index = '/opt/rename/snmp_files/ap_speed_index.txt'
    ap_mac_index = '/opt/rename/snmp_files/ap_mac_index.txt'
    ap_address_index = '/opt/rename/snmp_files/ap_address_index.txt'

    ApNameIndex = processfile(ap_name_index)
    ApSpeedIndex = processfile(ap_speed_index)
    ApMacIndex = processfile(ap_mac_index)
    ApAddressIndex = processfile(ap_address_index)

    ApDb = createdick(ApNameIndex,ApMacIndex,ApAddressIndex,ApSpeedIndex)
    delete_table()
    create_db()
    insert_data_db(ApDb)

if __name__ == '__main__':
    main()

Далее получаем данные из Google Sheets и пишем базу данных SQLite, процесс хорошо описан здесь, текст скрипта:

import logging 
import httplib2 
import apiclient.discovery 
from oauth2client.service_account import ServiceAccountCredentials 
from datetime import datetime, date, time 
import sqlite3 
logging.basicConfig(filename='/opt/rename/logg/rename_ap.log', filemode='w', format='%(name)s - %(levelname)s - %(message)s') 
def readgooglesheets(): 
   # Файл, полученный в Google Developer Console 
   CREDENTIALS_FILE = 'creds.json' 
   # ID Google Sheets документа (можно взять из его URL) 
   spreadsheet_id = 'id гугл документа' 
   # Авторизуемся и получаем service -- экземпляр доступа к API 
   credentials = ServiceAccountCredentials.from_json_keyfile_name( 
      CREDENTIALS_FILE, 
        ['https://www.googleapis.com/auth/spreadsheets', 
         'https://www.googleapis.com/auth/drive']) 
   httpAuth = credentials.authorize(httplib2.Http()) 
   service = apiclient.discovery.build('sheets', 'v4', http = httpAuth) 
   # чтения файла 
   values = service.spreadsheets().values().get( 
            spreadsheetId=spreadsheet_id, 
            range='CI3:CK9584', 
            majorDimension='COLUMNS' 
   # переменная values - словарь списков из указанных столбцов 
           ).execute() 
   ValuesList = values["values"] 
   return(ValuesList) 

def create_db(): 
   #Создает базу данных из гугл таблицы 
   conn = sqlite3.connect("/opt/rename/db/ApsSnmpDatabase.db") 
   cursor = conn.cursor() 
   #Создание таблицы 
   cursor.execute("""CREATE TABLE IF NOT EXISTS googleAps 
                 (ApMac TEXT ,  
                  ApName TEXT , 
                  AccDate TEXT) 
              """) 
   print('table googleAps created') 
   conn.commit() 
   cursor.close() 
   conn.close() 

def insert_data_db(ApDb): 
   #добавляет значения из гугл таблицы в базу 
   conn = sqlite3.connect("/opt/rename/db/ApsSnmpDatabase.db") 
   cursor = conn.cursor() 
   for n in range(len(ApDb[0])): 
	   # т.к. подрядчики не пишут мак адреса в едином виде, приводит к единому виду
       str = ApDb[0][n].replace('.', '').replace(':', '').upper() 
       # Из мак адреса гугл таблицы создается apname 
       if len(str) == 12: 
	       # вставляет "." через каждые 2 символа. 
           ApDb[0][n] = '.'.join(a + b for a, b in zip(str[::2], str[1::2])) 
       #ApDb - список из данных гугл таблицы 
       data = (ApDb[0][n], ApDb[1][n].lower(), ApDb[2][n]) 
       cursor.execute('INSERT INTO googleAps VALUES(?, ?, ?)', data) 
   print('added data table googleAps ') 
   conn.commit() 
   cursor.close() 
   conn.close() 

def delete_table(): 
   # Очищает таблицу
   conn = sqlite3.connect("/opt/rename/db/ApsSnmpDatabase.db") 
   cursor = conn.cursor() 
   query = "DROP TABLE IF EXISTS googleAps" 
   print('table googleAps deleted ') 
   cursor.execute(query) 
   conn.commit() 
   cursor.close() 
   conn.close() 

def renameap(): 
   # создает snmpget команды для переименования точек доступа
   conn = sqlite3.connect("/opt/rename/db/ApsSnmpDatabase.db") 
   cursor = conn.cursor() 
   # Делает выборку из 2-х таблиц по условию: мак адреса равны, имена не равны
   query = """SELECT ApName, accesspoints.snmp_index, accesspoints.wlc FROM accesspoints 
    JOIN googleAps ON accesspoints.mac = googleAps.ApMac and accesspoints.nameap != googleAps.ApName""" 
   cursor.execute(query) 
   data = cursor.fetchall() 
   wlc_cmd = [] 
   command='' 
   comunity = "write_comunity" 
   oid = '.1.3.6.1.4.1.9.9.513.1.1.1.1.5.' 
   for n in range(len(data)): 
	      # создает команды для переименования точек snmpget
          command = 'snmpset -v 2c -c ' + comunity + ' ' + data[n][2] + ' ' + oid + data[n][1] + ' s ' + data[n][0].lower() 
          # добавляет в список команду
          wlc_cmd.append(command) 
          logging.debug(command) 
   conn.commit() 
   cursor.close() 
   conn.close() 
   return wlc_cmd 

def writefile(filename, confs): 
   """" 
   Функция пишет список команд в файл 
    """ 
   print ('*** Writing ['+filename+'] ...') 
   try: 
      f = open(filename,'w') 
      for line in confs : 
          f.write(line + '\n') 
      f.close() 
   except: 
       logging.warning('Error writing file: ', filename) 
       sys.exit(1) 

def write_date(LGoogle): 
   # Функция добавляет текущую дату в гугл таблицу
   conn = sqlite3.connect("/opt/rename/db/ApsSnmpDatabase.db") 
   cursor = conn.cursor() 
   # Делает выборку из 2-х таблиц по условию: мак адреса равны, дата не заполнена
   query = """SELECT  accesspoints.nameap FROM accesspoints 
    JOIN googleAps ON accesspoints.mac = googleAps.ApMac and googleAps.AccDate = '' """ 
   cursor.execute(query) 
   data = cursor.fetchall() 
   conn.commit() 
   cursor.close() 
   conn.close() 
   now = datetime.now() 
   date_today = now.strftime("%d")+'/'+now.strftime("%m")+'/'+now.strftime("%Y") 
   DateGoogle = [] 
   i = 0 
   for n in LGoogle[1]: 
       for m in data: 
           if m[0] == n : 
               LGoogle[2][i] = date_today 
       DateGoogle.append([LGoogle[2][i]]) 
       i = i+1 
 
   CREDENTIALS_FILE = '/opt/rename/creds.json' 
   spreadsheet_id = 'id_google_table'
   credentials = ServiceAccountCredentials.from_json_keyfile_name( 
      CREDENTIALS_FILE, 
      ['https://www.googleapis.com/auth/spreadsheets', 
      'https://www.googleapis.com/auth/drive']) 
   httpAuth = credentials.authorize(httplib2.Http()) 
   service = apiclient.discovery.build('sheets', 'v4', http = httpAuth) 
   values = service.spreadsheets().values().batchUpdate( 
   spreadsheetId=spreadsheet_id, 
   body={ 
       "valueInputOption": "USER_ENTERED", 
       "data": [ 
           {"range": "CK3:CK9584", 
            "majorDimension": "ROWS", 
            "values": DateGoogle } 
           ] 
           } 
           ).execute() 

def main(): 
   wlc_conf = '/opt/rename/logg/wlc_commands.txt' 
   ListGoogle = readgooglesheets() 
   delete_table() 
   create_db() 
   insert_data_db(ListGoogle) 
   writefile(wlc_conf,renameap()) 
   write_date(ListGoogle) 

if __name__ == '__main__': 
   main()

Далее переименовываем точки доступа скриптом:

#!/bin/bash 
file='/opt/rename/logg/wlc_commands.txt' 
while read line ; 
do 
$line 
done < $file

Таким образом монтажники заполняют MAC адреса установленных точек доступа, при этом каждая точка доступа имеет свой hostname - наименование и номер, в соответствии со схемой расстановки. Скрипт на основании МАС адресов точек доступа, указанных монтажниками, переименовывает точки доступа.

Как же получить от монтажников правильные MAC адреса?

Вопрос конечно можно было попытаться решить административным путем, наказывая за ошибки и несоответствия рублем, но мы нашли более интересное решение. А именно telegram.bot, обратившись к которому с запросом содержащим “код объекта” можно было получить статус всех точек доступа на объекте, для этого используем библиотеку telebot, текст скрипта:

import telebot
import os
import time
import sqlite3

# Бот в группе парсит все сообщения. Если сообщение начинается на 'check ' обрабатывает это сообщение. SNMP запрос прописан в crontab, опрашивает контроллер каждую минуту.

bot = telebot.TeleBot('токен бота')

def read_db(map):
    try:
      conn = sqlite3.connect("/opt/rename/db/ApsSnmpDatabase.db")
      cursor = conn.cursor()
      query = """ SELECT  Apname, googleAps.APmac, accesspoints.duplex FROM googleAps
                  LEFT JOIN accesspoints ON accesspoints.mac = googleAps.ApMac 
                      WHERE ApName LIKE '{}___'
                      ORDER BY ApName ASC """.format(map)
      cursor.execute(query)
      data = cursor.fetchall()
      conn.commit()
      cursor.close()
      conn.close()
      return data
    except:
        bot.send_message(message.chat.id, 'Системная ошибка, повторите попытку через 5 секунд')
        time.sleep(1)

def create_message(AP_MAP):
  try:
    n = 0
    msg = ''
    for AP in AP_MAP:
          msg+='|{:<17s}|{:<19s}|{:>5s} Mbps|\n'.format(AP[0], AP[1], str(AP[2]))
            # < - примыкание слева ^- в центре 17s - количество символов в ячейке
          n = n + 1
    if n > 0:
        msg = 'Найдено {} точек доступа на объекте. \n<pre>'.format(n)+msg+'</pre>'
    else:
        msg = "нет такого объекта или неправильно введены данные"
    return msg
  except:
     bot.send_message(message.chat.id, 'Системная ошибка, повторите попытку через 5 секунд')
     time.sleep(1)

@bot.message_handler(content_types=['text'])
def send_text(message):
  try:
    # проверяет первые 6 символов, если совпадает с 'check' выполняется скрипт
    if message.text[:5].lower() == 'check':
        message.text = message.text.replace(message.text[:6],'') #Удаляет слово 'check '
        print(message)
        Ap_on_Map = read_db(message.text)
        msg = create_message(Ap_on_Map)
        bot.send_message(message.chat.id, msg, parse_mode='HTML')
  except:
    bot.send_message(message.chat.id, 'Системная ошибка, повторите попытку через 5 секунд')
    time.sleep(1)
bot.polling()

Пример работы бота:

  • none - говорит о том, что точка доступа не найдена ботом, возможно точка доступа не ассоциирована с контроллером или есть ошибка в MAC адресе;

  • 100 - говорит о том, что параметр eth0 speed точки доступа равен 100Mbps; 

  • 1000 - говорит о том, что параметр eth0 speed точки доступа равен 1000Mbps.

С учетом того, что точки доступа подключаются к PoE коммутатору с гиговыми портами, монтажники должны были самостоятельно добиваться параметра eth0 speed = 1000Mbps без нашего участия.

Таким образом был исключен сценарий, в котором нам пришлось бы долго и нудно выяснять почему точка доступа с MAC адресом 1 не наблюдается на контроллере или почему точка доступа  с MAC адресом 2 наблюдается на контроллере, при этом не понятно на каком объекте она стоит. А также исключены “терки” вида:

Мы: почему точка доступа 1 переименована как точка доступа 2?

Монтажник: ну так вы же переименовываете не мы;

Мы: тогда, что за точка ассоциировалась на контроллере, МАС адреса нет в таблице;

Монтажник: не знаем это не наша зона ответственности, все МАС адреса мы внесли в таблицу.

В общем кто хоть раз организовывал монтажные работы понимает о чем идет речь.

Приемка и анализ качества монтажных работ

Для того чтобы уложиться в сроки мы были вынуждены разрешить монтажникам удлинять кабельные линии при помощи соединителей, таких:

или при необходимости таких:

Эта вынужденная мера оправданная ограниченностью сроков и бюджетов оказалось неплохой идеей, но принесло нам дополнительные проблемы. Из за наличия соединений в кабельных линиях параметр eth0 speed на некоторых точках доступа постоянно флапал между значениями 100 и 1000, при этом в логах PoE коммутатора можно было встретить сообщения вида: detected poe-out status: current_too_low. Понятно, что со временем деградация таких кабельных линий может привести к невозможности предоставления сервиса. 

Как же не допустить приемку таких кабельных линий? Ответ нашелся довольно быстро с использованием свободного ПО Graphite. Хотелось бы обратить внимание на один нюанс. По умолчанию Graphite запоминает метрики только за последние 6 часов. Исправим это в файле конфигурации:

nano /path/to/graphite/configs/storage-schemas.conf[carbon] pattern = ^carbon. retentions = 60s:90d [default1minfor_1day] pattern = .* retentions = 1m:7d,5m:30d,30m:90d,24h:1y

Важно: Если были уже добавлены объекты, к ним это не примениться. Необходимо изменить это во всех wcp файлах:

sudo find ./ -type f -name '*.wsp' -exec whisper-resize --nobackup {} 1m:7d 5m:30d 30m:90d 24h:1y \; 

Далее пишем параметр eth0 speed получаемый с точек доступа отчетами каждые 5 минут в Graphite простым скриптом:

import graphyte
import sqlite3, sys
import logging

logging.basicConfig(filename='/opt/rename/logg/graphite.log', filemode='w', format='%(name)s - %(levelname)s - %(message)s')

graphyte.init('ip_address_graphite', prefix='aero')

def read_db():
	#Читает данные из sqlite
    conn = sqlite3.connect("/opt/rename/db/ApsSnmpDatabase.db")
    cursor = conn.cursor()
    # В мониторинг отправляются точки с заполненной датой ассоциации с контроллером.
    query = """ SELECT googleAps.ApName, accesspoints.duplex FROM googleAps
     LEFT JOIN accesspoints ON accesspoints.nameap = googleAps.ApName  
     WHERE googleAps.AccDate != '' AND googleAps.ApName != '' """
    cursor.execute(query)
    data = cursor.fetchall()
    conn.commit()
    cursor.close()
    conn.close()
    return data

def create_map():
    data = read_db()
    ApS = []
    # Если нет точки после опроса через snmp, Duplex =0
    for line in data:
          if line[1] == None:
              duplex = 0
          else:
              duplex = line[1]
# Создает map, т. к. В названиях некоторых объектах  есть символ «-», а он является разделителем для graphite, меняем его на «_»  
          map = line[0][:3]+'.'+line[0].replace(".", "_")[:len(line[0])-3]+'.'+line[0].replace(".", "_")
          ApS.append([map, int(duplex)])
    return ApS


def sendgraphite(map):
	# отправляет данные в graphite
    for data in map:
       #print(data[0],data[1])
       graphyte.send(data[0],data[1])

def main():
    Map = create_map()
    sendgraphite(Map)

if __name__ == '__main__':
    main()

Вот пример поведения точки доступа, видно, что интерфейс систематически меняет параметр speed, а иногда и совсем отваливается:

Полученные данные за 6 дней усредняем скриптом :

#!/bin/bash
/dev/null >  /opt/rename/average/average.txt
# maps_graphite_curl.txt  - файл с мапами
cat /opt/rename/graphite/maps_graphite_curl.txt | while read line
do
  curl  'http://10.10.10.10:8080/render/?from=-10080minutes&target=aliasByMetric(buiding.'$line')&format=csv' | awk -F "," '{print $3}' > /opt/rename/tmp/tmp.txt
  a=$(cat /opt/rename/tmp/tmp.txt | grep 0.0 | numsum)
  b=$(cat /opt/rename/tmp/tmp.txt | grep 0.0 | wc -l)
  map=$(echo $line | awk -F "." '{print $3}' | sed 's/_/./')
  echo $line
  echo $b 
  if [[ $b -ne 0 ]]; then 
    echo $map  $(($a/$b))  $(($b/288)) >> /opt/rename/average/average.txt;
  fi
done

И пишем в таблицу:

import httplib2
import apiclient.discovery
from oauth2client.service_account import ServiceAccountCredentials

def opengoogledocs(data):
    CREDENTIALS_FILE = '/opt/rename/creds.json'
    spreadsheet_id = 'id_таблицы'
    credentials = ServiceAccountCredentials.from_json_keyfile_name(
       CREDENTIALS_FILE,
       ['https://www.googleapis.com/auth/spreadsheets',
       'https://www.googleapis.com/auth/drive'])
    httpAuth = credentials.authorize(httplib2.Http())
    service = apiclient.discovery.build('sheets', 'v4', http = httpAuth)
    values = service.spreadsheets().values().batchUpdate(
    spreadsheetId=spreadsheet_id,
    body={
        "valueInputOption": "USER_ENTERED",
        "data": [
           # {"range": "B3:C4",
           #  "majorDimension": "ROWS",
           #  "values": [["This is B3", "This is C3"], ["This is B4", "This is C4"]]},
            {"range": "a1:d9584",
             "majorDimension": "ROWS",
             "values": data }
            ]
            }
            ).execute()
def processfile(filename):
    contents = []
    cnt = []
    print ('*** Reading ['+filename+'] ...')
    try:
        f = open(filename)
        contents = f.read().splitlines()
        f.close()
    except IOError:
        print ("Error opening file: ", filename)
        sys.exit(1)
    for line in contents:
        cnt.append(line.replace('_','.').split(' ')) 
    return cnt

def main():
    file = '/opt/rename/average/average.txt'
    AverageData = processfile(file)
    opengoogledocs(AverageData)

if __name__ == '__main__':
    main()

Путем опытного наблюдения за поведением уже смонтированных точек доступа, а их на момент написания статьи более 7500, приняли, что линии связи на которых точки доступа имеют средний параметр eth0 speed ниже 970, требуют реконструкции. Начиная от переобжимки RJ45 разъемов и заканчивая при необходимости заменой кабеля. Количество таких проблемных линий на момент написания статьи составило более 10% от числа наблюдаемых точек доступа.

Задача монтажников до нового года обеспечить на 100% кабельных линиях средний eth0 speed не ниже 970.

Надеюсь статья окажется полезной, вопросы пишите в комментарии ...