logo da vtnorton

📰 Outras postagens

Construindo minha Jarvis: automatizando minha casa

Construindo minha Jarvis: automatizando minha casa

por vítor norton, em 01/01/2025

Construindo minha Jarvis: automatizando minha casa

Como todos nós, começamos a automatizar a casa com fitas LED e luzes Wifi. “Alexa, hora do cinema” e apaga tudo e liga a TV. Uau! Claro que não é só de iluminação que vive uma casa autônoma, por isso eu talvez tenha feito um overengineering.
O propósito é ter tanta automação, tanta coisa acontecendo que a casa seja quase que viva, e se adapte de acordo com o que está acontecendo. Mais reativa e ativa do que ficar passiva atrás de um comando, mas temos que começar por algum lugar, né mesmo?
Por hora tenho luzes Wifi e fitas LED, pois resolvi começar aos poucos mesmo, com o que já tinha, mas já fiz um investimento após esse início - contarei mais pro final do artigo.
Meu maior desafio quanto a isso foi entender que:
  1. Me sinto mais confortável trabalhando com códigos, do que em plataforma de terceiros
  1. Não tô afim de pagar caro para sistemas de automação e que não vão se conectar com tudo o que quero
Como desenvolvedor, eu tenho poderes especiais, e quero abusar deles nisso. Por isso decidi que iria fazer um sistema que gerenciasse tudo aqui em casa. O problema? Não poderia ficar na nuvem. Poderia ser eloquente e falar de privacidade, segurança e o escambal, mas vou ser honesto: as fitas LED eu só consigo acessar pela rede local, se quisesse fazer um sistema externo, ia gastar pra trocar os controladores das fitas e não tava afim disso.
Por isso tenho um servidor próprio em casa, fica aqui, ligado 24/7. Ele faz mais coisas do que só a automação de casa, também é meu agent no Azure DevOps por exemplo. Porém como conseguir mandar um comando, de qualquer lugar do mundo, pro meu servidor local? A arquitetura disso ficou legal, mas primeiro vamos dar uma olhada no meu servidor.

Meu pequeno servidor local

Antes ele tava com um Ubuntu Server puro, só algumas coisas extras que havia instalado, como o pi-hole. Porém eu tava procurando uma maneira menos braçal de fazer as coisas e achei o CasaOS, um sistema que permite que eu gerencie de forma mais fácil vários sistemas.
Com ele instalei duas coisas: o Portainer e um DDNS-GO.
O Portainer é uma ferramenta de gerenciamento de containers Docker que me permite controlar todos os meus serviços através de uma interface web amigável e facilita implantar, monitorar e gerenciar meus containers.
O DDNS-GO, por sua vez, é um serviço que me permite acessar meu servidor local de qualquer lugar do mundo, mantendo um domínio atualizado mesmo com IP dinâmico. O que ele faz é basicamente monitorar constantemente o IP público do meu servidor e atualizar automaticamente os registros DNS quando há alterações. Assim, mesmo que meu provedor de internet mude meu IP (o que acontece com frequência), meu domínio sempre apontará para o endereço correto.
Para o DDNS-GO eu tive um contratempo… O servidor DNS do meu domínio está no Azure, e pelo DDNS-GO não tinha opção de configurar direto lá, teria ou que trocar meu servidor DNS ou comprar outro. Honestamente, queria só resolver rápido então acabei comprando outro domínio.
Assim, tudo no meu apartamento tem nomes de divindades gregas, o meu celular é o Hermes, meu relógio o Cronos, meu PC é o Zeus. O servidor é o Hefesto. Já deu pre entender a lógica né? Onde eles estão? Claro! No Monte Olímpio, e foi exatamente esse domínio que comprei.
Momento lúdico de lado, fiz besteira e comprei o domínio na GoDaddy - que é onde todos os meus domínios estão. Por que besteira se eles são bons e eu gosto do suporte? Porque a gestão de domínios via API deles é só se você tiver mais de 10 domínios, e eu não os tenho. Significa que o meu DDNS-GO não conseguiria atualizar o registro DNS com meu novo endereço de IP. Tive que jogar meu servidor DNS pra CloudFlare.
Ok vai, funcionou. Não é o ideal, mas é problema pro meu eu do futuro. Pretendo atualizar o hardware do meu servidor em breve, e com isso resolver alguns débitos técnicos como esse. Ainda não sei exatamente como, mas talvez eu troque o DDNS-GO por alguma outra coisa - ou eu mesmo faça - e já atualize direto no Azure.
Essa foi a parte mais problemática do meu servidor local, mas foi importante pois a maneira como iria atualizar e fazer deploys nele, eu precisaria de acessar o servidor pela rede global.
 

DevOps disso tudo

Tá, aqui talvez não seja meu momento de brilhar. Não sou expert de Linux nem de DevOps, provavelmente há maneiras mais interessantes de fazer o que fiz, mas depois de várias noites sem dormir, só queria que funciona-se, e funcionou. Agora é melhorando continuamente, e se você tiver uma sugestão de melhorar isso, ficarei grato em ouvir.
Basicamente fiz uma aplicação node, em TypeScript pois sou doido mas nem tanto, que fica executando ad eterno. Com isso fiz só uma imagem docker mesmo, e publico ela.
FROM node:20-slim AS build WORKDIR /app COPY package.json yarn.lock ./ RUN yarn install COPY . . RUN yarn build FROM node:20-slim AS production WORKDIR /app COPY package.json yarn.lock ./ RUN yarn install --production COPY --from=build /app/build ./build CMD ["node", "build/index.js"]
Pra ser sincero, o comando pra rodar a aplicação no Dockerfile não é util em nada, mas ta aí. O maior desafio do rolê todo foi entender que estava montando uma aplicação monorepo (com, por hora, dois projetos). Isso atrapalhou um pouco por eu não ter tido tanta experiência em monorepo node.
Ponto importante aqui, foi usar a imagem node:20-slim, isso fez com que a imagem não ficasse muito grande.
Em seguida foi o docker compose:
version: '3.8' services: home-controllers: image: monteolimpio.azurecr.io/vtnorton/home-controllers:latest container_name: home-controllers environment: - SERVICE_BUS_CONNECTION_STRING=${0} - TUYA_ACCESS_KEY=${1} - TUYA_SECRET_KEY=${2} - OPEN_WEATHER=${3} ports: - "3000:3000" command: node /app/build/index.js
Bem simplesão, bem direto. O problema ficou mais no workflow do GitHub Actions, pois eu queria evitar de subir um registro de contêineres para isso, por mim só jogava o arquivo .tar da imagem no servidor, usando algum tipo de conexão SSL e sucesso, mas o upload tava muito lento pros 200Mb e depois de várias tentativas… e eu te garanto foram várias, acabei cedendo a criar um registro de imagem privado no meu Azure pra isso.
name: Update Hefesto on: push: branches: - main paths: - 'home-controllers/**' jobs: build: runs-on: ubuntu-latest steps: - name: Checkout do código uses: actions/checkout@v3 - name: Configurar Docker Buildx uses: docker/setup-buildx-action@v2 - name: Build da Imagem Docker working-directory: ./home-controllers run: | docker build -t monteolimpio.azurecr.io/vtnorton/home-controllers . - name: Login no Azure Container Registry uses: azure/docker-login@v1 with: login-server: monteolimpio.azurecr.io username: ${{ secrets.AZURE_USERNAME }} password: ${{ secrets.AZURE_PASSWORD }} - name: Push da Imagem para o Azure Container Registry run: | docker push monteolimpio.azurecr.io/vtnorton/home-controllers - name: Configurar chave SSH run: | mkdir -p ~/.ssh echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan -H hefesto.monteolimpio.com >> ~/.ssh/known_hosts - name: Substituir variáveis no README uses: richardrigutins/replace-in-files@v2 with: files: './home-controllers/docker-compose.yaml' search-text: '${0}' replacement-text: ${{ secrets.SERVICE_BUS_CONNECTION_STRING }} - name: Substituir variáveis no README uses: richardrigutins/replace-in-files@v2 with: files: './home-controllers/docker-compose.yaml' search-text: '${1}' replacement-text: ${{ secrets.TUYA_ACCESS_KEY }} - name: Substituir variáveis no README uses: richardrigutins/replace-in-files@v2 with: files: './home-controllers/docker-compose.yaml' search-text: '${2}' replacement-text: ${{ secrets.TUYA_SECRET_KEY }} - name: Substituir variáveis no README uses: richardrigutins/replace-in-files@v2 with: files: './home-controllers/docker-compose.yaml' search-text: '${3}' replacement-text: ${{ secrets.OPEN_WEATHER }} - name: Transferir docker-compose para o servidor working-directory: ./home-controllers run: | scp docker-compose.yaml vtnorton@hefesto.monteolimpio.com:/home/vtnorton/ - name: Conectar ao servidor via SSH e puxar a nova imagem run: | ssh vtnorton@hefesto.monteolimpio.com 'docker-compose pull home-controllers' - name: Conectar ao servidor via SSH e atualizar os containers run: | ssh vtnorton@hefesto.monteolimpio.com 'docker-compose up -d' - name: Remover imagens não utilizadas run: | ssh vtnorton@hefesto.monteolimpio.com 'docker image prune -f'
No fim deu certo, estou feliz com o resultado, como alguém que não é expert em DevOps, essa tarefa me ajudou a revisitar muita coisa e principalmente aprender alguns conceitos novos, mas tenho a ligeira impressão que alguém que manja mais do que eu iria fazer bem diferente.
Especialmente na parte de copiar o docker-compose para o meu servidor com as chaves de ambiente. Mas aí, eu sou ignorante mesmo, não sei fazer. Ayudame!
 

O Entrypoint

Como mencionei antes, são dois projetos nesse repositório, e antes de ir para o principal, preciso falar do entrypoint, que eu chamei carinhosamente de aquiles-entrypoint, o momento nerd de literatura grega chegará mais pra frente nesse texto.
Basicamente não queria deixar meu servidor exposto na internet - sim via SSL tá exposto - mas não queria ter que lidar com abrir portas HTTP e etc. quis fazer um ponto de entrada pra tudo isso.
Como ponto de entrada, fiz um projeto serverless de Azure Functions com node (eu já tinha feito com .NET antes, e quis experimentar fazer com TypeScript dessa vez), que fica escutando requisições POST em um endpoint. Devidamente autenticado obviamente.
Ao receber um novo POST ele envia para uma fila do Azure Service Bus uma mensagem que o servidor está escutando, e a partir daí faz o que precisa ser feito.
O momento nerd chegou: a nomenclatura de tudo. Bom, o servidor é o Hefesto, ele recebe mensagens de alguém (não poderia ser Hermes pois meus celular já é o Hermes), e alguém manda essa mensagem. Hmmm, que história da mitologia grega eu posso usar? [digito isso enquanto encaro a Ilíada que está me encarando aqui no meu escritório].
Na Ilíada temos um trecho que diz sobre Tétis, mãe de Aquiles, indo até Hefesto para pedir que fizesse uma nova armadura para seu filho após a morte de Patróclo (assista Troia). Da mesma forma, meu entrypoint, chamado de Aquiles, envia mensagens para Hefesto (meu servidor) através do Service Bus que ganhou o nome de Tétis (na verdade, em inglês Thetis pois precisava de seis letras). A analogia ficou perfeita: assim como Tétis era intermediária entre Aquiles e Hefesto, o Service Bus (Tétis) é o intermediário entre meu entrypoint (Aquiles) e meu servidor (Hefesto).
Pra mim ainda ganhou um significado especial pois Tétis frequenta o monte olímpio enquanto Aquiles não. E com isso ganhei um novo conceito de nomes para esse meu projeto, os serviços que rodam fora do meu servidor local não terão nomes de divindades gregas, e sim de heróis ou outros personagens que não fazem parte da seleção de moradores do Monte Olímpio.

Home Controller

Este é o projeto principal, e nele estou aplicando o conceito de agilidade que é melhoria constante. Por hora ainda tem muita coisa hard coded, e que o Uncle Bob choraria de ver (não que eu me importe com ele).
Basicamente é um projeto Node que fica escutando a minha mensageria Thetis e se encarrega de executar o que estou chamando de rotinas.
const routines: { [key: string]: any } = { 'SLEEP': SleepRoutine, 'CINEMA': CinemaRoutine, ... }; if (routine in routines) await routines[routine].Run()
O que é uma rotina? Bom, é basicamente quando eu falo que vou ver um filme, ir dormir, ou começar um streaming.
No caso de ir dormir, o que ele faz é:
import { runAt } from '../adapters/SchedulerAdapter' import { LedsServices } from '../services/LedsServices' import { PowerPlugServices } from '../services/PowerPlugServices' import { WeatherServices } from '../services/WeatherServices' import { sendInfraredCommand } from '../adapters/TuyaAdapter' import { TuyaAirConditionerCommand } from '../types/Tuya/TuyaAirConditioner' import { InfraRedControllers, ledStripers } from '../database/devices' export class SleepRoutine { Run = async () => { const date = new Date() console.log(`🌙 ${date.getHours()}:${date.getMinutes()} - Iniciando rotina de sono...`) const bedsideLamp = new PowerPlugServices() await bedsideLamp.TurnOn(1500) // 25m const livingRoomLedStrip = new LedsServices(ledStripers[0]) livingRoomLedStrip.LightItUp('255, 135, 24') runAt(300, () => { livingRoomLedStrip.LightItUp('65, 34, 6') }) runAt(360, () => { livingRoomLedStrip.TurnItOff() }) const wheaterServices = new WeatherServices() const temparature = wheaterServices.GetTemperature() if (temparature > 21) { const command = new TuyaAirConditionerCommand(true) sendInfraredCommand(InfraRedControllers[0].DeviceId, InfraRedControllers[0].ControllerId, command) } console.log('🌙 Rotina de sono finalizada!') } }
Sim, estou fazendo logs no console, quero separar isso em uma abstração e também quero centralizar os logs deste projeto e de tantos outros em um, talvez, Elastic da vida (só pra pegar mais prática com ele).
Mas basicamente o que ele está fazendo é ligar o abajur do quarto por 25 minutos (tempo suficiente pra eu me arrumar), ligar a fita de LED da sala em um tom alaranjado que vai diminuindo a intensidade gradualmente até desligar completamente após 6 minutos - pois normalmente ativo essa rotina antes de ir para o quarto. Se a temperatura estiver acima de 21 graus, ele liga o ar condicionado também.
E aqui está o investimento para além de Fita LED - por hora, que é o emissor de Infra Vermelho, que por hora está sendo responsável por controlar o Ar Condicionado, mas também já me permite usar meu celular como controle da TV do quarto.
Ainda pretendo colocar mais coisas nessa rotina, como um sensor de humidade no quarto que controlará um humidificador enquanto durmo, um motor na minha cortina que a fechará se não estiver fechada, um sensor de presença que adaptará esse código caso tenha mais pessoas dormindo aqui em casa. E por aí vai.
Resolvi manter o projeto privado no GitHub, pois é um projeto muito específico para minha casa e necessidades, revelando algumas coisas e rotinas que prefiro manter privado. Talvez no futuro eu abra partes do código, veremos.

O funcionamento

Por hora, tenho usando o Shortcuts da Apple para fazer o envio desse POST, ele também coloca meu celular no modo Dormir e que por sua vez coloca no modo economia de bateria. Por usar o Shortcuts, a Siri consegue entender quando eu falo que estou indo dormir, e tudo isso já acontece. Sei que minha Jarvis não será com a Siri, mas por hora, tem quebrado esse galho.
 

Concluindo

Provavelmente há uma maneira mais fácil de fazer isso, eu tentei fazer o intermediário, pois queria a habilidade de ter o controle absoluto do código, acho que só assim conseguiria ter o resultado que eu gostaria.
Bom, a meta agora é adicionar pelo menos duzentas rotinas e automações dentro do hefesto até o fim de 2025. Contarei mais sobre isso aqui, fique ligado!