mirror of
https://github.com/esphome/esphome.git
synced 2024-11-04 09:01:53 +01:00
513 lines
18 KiB
Python
513 lines
18 KiB
Python
from __future__ import print_function
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import random
|
|
import sys
|
|
from datetime import datetime
|
|
|
|
from esphomeyaml import const, core, core_config, mqtt, wizard, writer, yaml_util
|
|
from esphomeyaml.config import get_component, iter_components, read_config
|
|
from esphomeyaml.const import CONF_BAUD_RATE, CONF_BUILD_PATH, CONF_DOMAIN, CONF_ESPHOMEYAML, \
|
|
CONF_HOSTNAME, CONF_LOGGER, CONF_MANUAL_IP, CONF_NAME, CONF_STATIC_IP, CONF_USE_CUSTOM_CODE, \
|
|
CONF_WIFI, ESP_PLATFORM_ESP8266
|
|
from esphomeyaml.core import ESPHomeYAMLError
|
|
from esphomeyaml.helpers import AssignmentExpression, Expression, RawStatement, \
|
|
_EXPRESSIONS, add, \
|
|
add_job, color, flush_tasks, indent, quote, statement, relative_path
|
|
from esphomeyaml.util import safe_print
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
PRE_INITIALIZE = ['esphomeyaml', 'logger', 'wifi', 'ota', 'mqtt', 'web_server', 'i2c']
|
|
|
|
|
|
def get_serial_ports():
|
|
# from https://github.com/pyserial/pyserial/blob/master/serial/tools/list_ports.py
|
|
from serial.tools.list_ports import comports
|
|
result = []
|
|
for port, desc, info in comports():
|
|
if not port:
|
|
continue
|
|
if "VID:PID" in info:
|
|
result.append((port, desc))
|
|
return result
|
|
|
|
|
|
def choose_serial_port(config):
|
|
result = get_serial_ports()
|
|
|
|
if not result:
|
|
return 'OTA'
|
|
safe_print(u"Found multiple serial port options, please choose one:")
|
|
for i, (res, desc) in enumerate(result):
|
|
safe_print(u" [{}] {} ({})".format(i, res, desc))
|
|
safe_print(u" [{}] Over The Air ({})".format(len(result), get_upload_host(config)))
|
|
safe_print()
|
|
while True:
|
|
opt = raw_input('(number): ')
|
|
if opt in result:
|
|
opt = result.index(opt)
|
|
break
|
|
try:
|
|
opt = int(opt)
|
|
if opt < 0 or opt > len(result):
|
|
raise ValueError
|
|
break
|
|
except ValueError:
|
|
safe_print(color('red', u"Invalid option: '{}'".format(opt)))
|
|
if opt == len(result):
|
|
return 'OTA'
|
|
return result[opt][0]
|
|
|
|
|
|
def run_platformio(*cmd, **kwargs):
|
|
def mock_exit(return_code):
|
|
raise SystemExit(return_code)
|
|
|
|
orig_argv = sys.argv
|
|
orig_exit = sys.exit # mock sys.exit
|
|
full_cmd = u' '.join(quote(x) for x in cmd)
|
|
_LOGGER.info(u"Running: %s", full_cmd)
|
|
try:
|
|
func = kwargs.get('main')
|
|
if func is None:
|
|
import platformio.__main__
|
|
func = platformio.__main__.main
|
|
sys.argv = list(cmd)
|
|
sys.exit = mock_exit
|
|
return func() or 0
|
|
except KeyboardInterrupt:
|
|
return 1
|
|
except SystemExit as err:
|
|
return err.args[0]
|
|
except Exception as err: # pylint: disable=broad-except
|
|
_LOGGER.error(u"Running platformio failed: %s", err)
|
|
_LOGGER.error(u"Please try running %s locally.", full_cmd)
|
|
finally:
|
|
sys.argv = orig_argv
|
|
sys.exit = orig_exit
|
|
|
|
|
|
def run_miniterm(config, port, escape=False):
|
|
import serial
|
|
if CONF_LOGGER not in config:
|
|
_LOGGER.info("Logger is not enabled. Not starting UART logs.")
|
|
return
|
|
baud_rate = config['logger'][CONF_BAUD_RATE]
|
|
if baud_rate == 0:
|
|
_LOGGER.info("UART logging is disabled (baud_rate=0). Not starting UART logs.")
|
|
_LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate)
|
|
|
|
with serial.Serial(port, baudrate=baud_rate) as ser:
|
|
while True:
|
|
try:
|
|
raw = ser.readline()
|
|
except serial.SerialException:
|
|
_LOGGER.error("Serial port closed!")
|
|
return
|
|
line = raw.replace('\r', '').replace('\n', '')
|
|
time = datetime.now().time().strftime('[%H:%M:%S]')
|
|
message = time + line
|
|
if escape:
|
|
message = message.replace('\033', '\\033')
|
|
safe_print(message)
|
|
|
|
|
|
def write_cpp(config):
|
|
_LOGGER.info("Generating C++ source...")
|
|
|
|
add_job(core_config.to_code, config[CONF_ESPHOMEYAML], domain='esphomeyaml')
|
|
for domain in PRE_INITIALIZE:
|
|
if domain == CONF_ESPHOMEYAML or domain not in config:
|
|
continue
|
|
add_job(get_component(domain).to_code, config[domain], domain=domain)
|
|
|
|
for domain, component, conf in iter_components(config):
|
|
if domain in PRE_INITIALIZE or not hasattr(component, 'to_code'):
|
|
continue
|
|
add_job(component.to_code, conf, domain=domain)
|
|
|
|
flush_tasks()
|
|
add(RawStatement(''))
|
|
add(RawStatement(''))
|
|
all_code = []
|
|
for exp in _EXPRESSIONS:
|
|
if not config[CONF_ESPHOMEYAML][CONF_USE_CUSTOM_CODE]:
|
|
if isinstance(exp, Expression) and not exp.required:
|
|
continue
|
|
if isinstance(exp, AssignmentExpression) and not exp.obj.required:
|
|
if not exp.has_side_effects():
|
|
continue
|
|
exp = exp.rhs
|
|
all_code.append(unicode(statement(exp)))
|
|
|
|
build_path = relative_path(config[CONF_ESPHOMEYAML][CONF_BUILD_PATH])
|
|
writer.write_platformio_project(config, build_path)
|
|
|
|
code_s = indent('\n'.join(line.rstrip() for line in all_code))
|
|
cpp_path = os.path.join(build_path, 'src', 'main.cpp')
|
|
writer.write_cpp(code_s, cpp_path)
|
|
return 0
|
|
|
|
|
|
def compile_program(args, config):
|
|
_LOGGER.info("Compiling app...")
|
|
build_path = relative_path(config[CONF_ESPHOMEYAML][CONF_BUILD_PATH])
|
|
command = ['platformio', 'run', '-d', build_path]
|
|
if args.verbose:
|
|
command.append('-v')
|
|
return run_platformio(*command)
|
|
|
|
|
|
def get_upload_host(config):
|
|
if CONF_MANUAL_IP in config[CONF_WIFI]:
|
|
host = str(config[CONF_WIFI][CONF_MANUAL_IP][CONF_STATIC_IP])
|
|
elif CONF_HOSTNAME in config[CONF_WIFI]:
|
|
host = config[CONF_WIFI][CONF_HOSTNAME] + config[CONF_WIFI][CONF_DOMAIN]
|
|
else:
|
|
host = config[CONF_ESPHOMEYAML][CONF_NAME] + config[CONF_WIFI][CONF_DOMAIN]
|
|
return host
|
|
|
|
|
|
def upload_using_esptool(config, port):
|
|
import esptool
|
|
|
|
build_path = relative_path(config[CONF_ESPHOMEYAML][CONF_BUILD_PATH])
|
|
path = os.path.join(build_path, '.pioenvs', core.NAME, 'firmware.bin')
|
|
# pylint: disable=protected-access
|
|
return run_platformio('esptool.py', '--before', 'default_reset', '--after', 'hard_reset',
|
|
'--chip', 'esp8266', '--port', port, 'write_flash', '0x0',
|
|
path, main=esptool._main)
|
|
|
|
|
|
def upload_program(config, args, port):
|
|
build_path = relative_path(config[CONF_ESPHOMEYAML][CONF_BUILD_PATH])
|
|
|
|
# if upload is to a serial port use platformio, otherwise assume ota
|
|
serial_port = port.startswith('/') or port.startswith('COM')
|
|
if port != 'OTA' and serial_port:
|
|
if core.ESP_PLATFORM == ESP_PLATFORM_ESP8266 and args.use_esptoolpy:
|
|
return upload_using_esptool(config, port)
|
|
command = ['platformio', 'run', '-d', build_path,
|
|
'-t', 'upload', '--upload-port', port]
|
|
if args.verbose:
|
|
command.append('-v')
|
|
return run_platformio(*command)
|
|
|
|
if 'ota' not in config:
|
|
_LOGGER.error("No serial port found and OTA not enabled. Can't upload!")
|
|
return -1
|
|
|
|
# If hostname/ip is explicitly provided as upload-port argument, use this instead of zeroconf
|
|
# hostname. This is to support use cases where zeroconf (hostname.local) does not work.
|
|
if port != 'OTA':
|
|
host = port
|
|
else:
|
|
host = get_upload_host(config)
|
|
|
|
from esphomeyaml.components import ota
|
|
from esphomeyaml import espota2
|
|
|
|
bin_file = os.path.join(build_path, '.pioenvs', core.NAME, 'firmware.bin')
|
|
if args.host_port is not None:
|
|
host_port = args.host_port
|
|
else:
|
|
host_port = int(os.getenv('ESPHOMEYAML_OTA_HOST_PORT', random.randint(10000, 60000)))
|
|
|
|
verbose = args.verbose
|
|
remote_port = ota.get_port(config)
|
|
password = ota.get_auth(config)
|
|
|
|
res = espota2.run_ota(host, remote_port, password, bin_file)
|
|
if res == 0:
|
|
return res
|
|
_LOGGER.warn("OTA v2 method failed. Trying with legacy OTA...")
|
|
return espota2.run_legacy_ota(verbose, host_port, host, remote_port, password, bin_file)
|
|
|
|
|
|
def show_logs(config, args, port, escape=False):
|
|
serial_port = port.startswith('/') or port.startswith('COM')
|
|
if port != 'OTA' and serial_port:
|
|
run_miniterm(config, port, escape=escape)
|
|
return 0
|
|
return mqtt.show_logs(config, args.topic, args.username, args.password, args.client_id,
|
|
escape=escape)
|
|
|
|
|
|
def clean_mqtt(config, args):
|
|
return mqtt.clear_topic(config, args.topic, args.username, args.password, args.client_id)
|
|
|
|
|
|
def setup_log(debug=False):
|
|
log_level = logging.DEBUG if debug else logging.INFO
|
|
logging.basicConfig(level=log_level)
|
|
fmt = "%(levelname)s [%(name)s] %(message)s"
|
|
colorfmt = "%(log_color)s{}%(reset)s".format(fmt)
|
|
datefmt = '%H:%M:%S'
|
|
|
|
logging.getLogger('urllib3').setLevel(logging.WARNING)
|
|
|
|
try:
|
|
from colorlog import ColoredFormatter
|
|
logging.getLogger().handlers[0].setFormatter(ColoredFormatter(
|
|
colorfmt,
|
|
datefmt=datefmt,
|
|
reset=True,
|
|
log_colors={
|
|
'DEBUG': 'cyan',
|
|
'INFO': 'green',
|
|
'WARNING': 'yellow',
|
|
'ERROR': 'red',
|
|
'CRITICAL': 'red',
|
|
}
|
|
))
|
|
except ImportError:
|
|
pass
|
|
|
|
|
|
def command_wizard(args):
|
|
return wizard.wizard(args.configuration)
|
|
|
|
|
|
def strip_default_ids(config):
|
|
value = config
|
|
if isinstance(config, list):
|
|
value = type(config)()
|
|
for x in config:
|
|
if isinstance(x, core.ID) and not x.is_manual:
|
|
continue
|
|
value.append(strip_default_ids(x))
|
|
return value
|
|
elif isinstance(config, dict):
|
|
value = type(config)()
|
|
for k, v in config.iteritems():
|
|
if isinstance(v, core.ID) and not v.is_manual:
|
|
continue
|
|
value[k] = strip_default_ids(v)
|
|
return value
|
|
return value
|
|
|
|
|
|
def command_config(args, config):
|
|
if not args.verbose:
|
|
config = strip_default_ids(config)
|
|
safe_print(yaml_util.dump(config))
|
|
return 0
|
|
|
|
|
|
def command_compile(args, config):
|
|
exit_code = write_cpp(config)
|
|
if exit_code != 0:
|
|
return exit_code
|
|
if args.only_generate:
|
|
_LOGGER.info(u"Successfully generated source code.")
|
|
return 0
|
|
exit_code = compile_program(args, config)
|
|
if exit_code != 0:
|
|
return exit_code
|
|
_LOGGER.info(u"Successfully compiled program.")
|
|
return 0
|
|
|
|
|
|
def command_upload(args, config):
|
|
port = args.upload_port or choose_serial_port(config)
|
|
exit_code = upload_program(config, args, port)
|
|
if exit_code != 0:
|
|
return exit_code
|
|
_LOGGER.info(u"Successfully uploaded program.")
|
|
return 0
|
|
|
|
|
|
def command_logs(args, config):
|
|
port = args.serial_port or choose_serial_port(config)
|
|
return show_logs(config, args, port, escape=args.escape)
|
|
|
|
|
|
def command_run(args, config):
|
|
exit_code = write_cpp(config)
|
|
if exit_code != 0:
|
|
return exit_code
|
|
exit_code = compile_program(args, config)
|
|
if exit_code != 0:
|
|
return exit_code
|
|
_LOGGER.info(u"Successfully compiled program.")
|
|
port = args.upload_port or choose_serial_port(config)
|
|
exit_code = upload_program(config, args, port)
|
|
if exit_code != 0:
|
|
return exit_code
|
|
_LOGGER.info(u"Successfully uploaded program.")
|
|
if args.no_logs:
|
|
return 0
|
|
return show_logs(config, args, port, escape=args.escape)
|
|
|
|
|
|
def command_clean_mqtt(args, config):
|
|
return clean_mqtt(config, args)
|
|
|
|
|
|
def command_mqtt_fingerprint(args, config):
|
|
return mqtt.get_fingerprint(config)
|
|
|
|
|
|
def command_version(args):
|
|
safe_print(u"Version: {}".format(const.__version__))
|
|
return 0
|
|
|
|
|
|
def command_clean(args, config):
|
|
build_path = relative_path(config[CONF_ESPHOMEYAML][CONF_BUILD_PATH])
|
|
try:
|
|
writer.clean_build(build_path)
|
|
except OSError as err:
|
|
_LOGGER.error("Error deleting build files: %s", err)
|
|
return 1
|
|
_LOGGER.info("Done!")
|
|
return 0
|
|
|
|
|
|
def command_dashboard(args):
|
|
from esphomeyaml.dashboard import dashboard
|
|
|
|
return dashboard.start_web_server(args)
|
|
|
|
|
|
PRE_CONFIG_ACTIONS = {
|
|
'wizard': command_wizard,
|
|
'version': command_version,
|
|
'dashboard': command_dashboard
|
|
}
|
|
|
|
POST_CONFIG_ACTIONS = {
|
|
'config': command_config,
|
|
'compile': command_compile,
|
|
'upload': command_upload,
|
|
'logs': command_logs,
|
|
'run': command_run,
|
|
'clean-mqtt': command_clean_mqtt,
|
|
'mqtt-fingerprint': command_mqtt_fingerprint,
|
|
'clean': command_clean,
|
|
}
|
|
|
|
|
|
def parse_args(argv):
|
|
parser = argparse.ArgumentParser(prog='esphomeyaml')
|
|
parser.add_argument('-v', '--verbose', help="Enable verbose esphomeyaml logs.",
|
|
action='store_true')
|
|
parser.add_argument('configuration', help='Your YAML configuration file.')
|
|
|
|
subparsers = parser.add_subparsers(help='Commands', dest='command')
|
|
subparsers.required = True
|
|
subparsers.add_parser('config', help='Validate the configuration and spit it out.')
|
|
|
|
parser_compile = subparsers.add_parser('compile',
|
|
help='Read the configuration and compile a program.')
|
|
parser_compile.add_argument('--only-generate',
|
|
help="Only generate source code, do not compile.",
|
|
action='store_true')
|
|
|
|
parser_upload = subparsers.add_parser('upload', help='Validate the configuration '
|
|
'and upload the latest binary.')
|
|
parser_upload.add_argument('--upload-port', help="Manually specify the upload port to use. "
|
|
"For example /dev/cu.SLAB_USBtoUART.")
|
|
parser_upload.add_argument('--host-port', help="Specify the host port.", type=int)
|
|
parser_upload.add_argument('--use-esptoolpy',
|
|
help="Use esptool.py for the uploading (only for ESP8266)",
|
|
action='store_true')
|
|
|
|
parser_logs = subparsers.add_parser('logs', help='Validate the configuration '
|
|
'and show all MQTT logs.')
|
|
parser_logs.add_argument('--topic', help='Manually set the topic to subscribe to.')
|
|
parser_logs.add_argument('--username', help='Manually set the username.')
|
|
parser_logs.add_argument('--password', help='Manually set the password.')
|
|
parser_logs.add_argument('--client-id', help='Manually set the client id.')
|
|
parser_logs.add_argument('--serial-port', help="Manually specify a serial port to use"
|
|
"For example /dev/cu.SLAB_USBtoUART.")
|
|
parser_logs.add_argument('--escape', help="Escape ANSI color codes for running in dashboard",
|
|
action='store_true')
|
|
|
|
parser_run = subparsers.add_parser('run', help='Validate the configuration, create a binary, '
|
|
'upload it, and start MQTT logs.')
|
|
parser_run.add_argument('--upload-port', help="Manually specify the upload port/ip to use. "
|
|
"For example /dev/cu.SLAB_USBtoUART.")
|
|
parser_run.add_argument('--host-port', help="Specify the host port to use for OTA", type=int)
|
|
parser_run.add_argument('--no-logs', help='Disable starting MQTT logs.',
|
|
action='store_true')
|
|
parser_run.add_argument('--topic', help='Manually set the topic to subscribe to for logs.')
|
|
parser_run.add_argument('--username', help='Manually set the MQTT username for logs.')
|
|
parser_run.add_argument('--password', help='Manually set the MQTT password for logs.')
|
|
parser_run.add_argument('--client-id', help='Manually set the client id for logs.')
|
|
parser_run.add_argument('--escape', help="Escape ANSI color codes for running in dashboard",
|
|
action='store_true')
|
|
parser_run.add_argument('--use-esptoolpy',
|
|
help="Use esptool.py for the uploading (only for ESP8266)",
|
|
action='store_true')
|
|
|
|
parser_clean = subparsers.add_parser('clean-mqtt', help="Helper to clear an MQTT topic from "
|
|
"retain messages.")
|
|
parser_clean.add_argument('--topic', help='Manually set the topic to subscribe to.')
|
|
parser_clean.add_argument('--username', help='Manually set the username.')
|
|
parser_clean.add_argument('--password', help='Manually set the password.')
|
|
parser_clean.add_argument('--client-id', help='Manually set the client id.')
|
|
|
|
subparsers.add_parser('wizard', help="A helpful setup wizard that will guide "
|
|
"you through setting up esphomeyaml.")
|
|
|
|
subparsers.add_parser('mqtt-fingerprint', help="Get the SSL fingerprint from a MQTT broker.")
|
|
|
|
subparsers.add_parser('version', help="Print the esphomeyaml version and exit.")
|
|
|
|
subparsers.add_parser('clean', help="Delete all temporary build files.")
|
|
|
|
dashboard = subparsers.add_parser('dashboard',
|
|
help="Create a simple web server for a dashboard.")
|
|
dashboard.add_argument("--port", help="The HTTP port to open connections on.", type=int,
|
|
default=6052)
|
|
dashboard.add_argument("--password", help="The optional password to require for all requests.",
|
|
type=str, default='')
|
|
dashboard.add_argument("--open-ui", help="Open the dashboard UI in a browser.",
|
|
action='store_true')
|
|
|
|
return parser.parse_args(argv[1:])
|
|
|
|
|
|
def run_esphomeyaml(argv):
|
|
args = parse_args(argv)
|
|
setup_log(args.verbose)
|
|
if args.command in PRE_CONFIG_ACTIONS:
|
|
try:
|
|
return PRE_CONFIG_ACTIONS[args.command](args)
|
|
except ESPHomeYAMLError as e:
|
|
_LOGGER.error(e)
|
|
return 1
|
|
|
|
core.CONFIG_PATH = args.configuration
|
|
|
|
config = read_config(core.CONFIG_PATH)
|
|
if config is None:
|
|
return 1
|
|
|
|
if args.command in POST_CONFIG_ACTIONS:
|
|
try:
|
|
return POST_CONFIG_ACTIONS[args.command](args, config)
|
|
except ESPHomeYAMLError as e:
|
|
_LOGGER.error(e)
|
|
return 1
|
|
safe_print(u"Unknown command {}".format(args.command))
|
|
return 1
|
|
|
|
|
|
def main():
|
|
try:
|
|
return run_esphomeyaml(sys.argv)
|
|
except ESPHomeYAMLError as e:
|
|
_LOGGER.error(e)
|
|
return 1
|
|
except KeyboardInterrupt:
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|