13.04.2026

This commit is contained in:
2026-04-13 23:37:21 +02:00
parent 857a895d51
commit 461168b879
24 changed files with 5661 additions and 0 deletions

1
.gitignore vendored
View File

@@ -26,6 +26,7 @@ share/python-wheels/
.installed.cfg
*.egg
MANIFEST
node_modules/
# PyInstaller
# Usually these files are written by a python script from a template

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,97 @@
esphome:
name: main-hub
friendly_name: main-hub
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
api:
encryption:
key: "eyBNZWDUaBDCTIZsSIUzwHFpUcm/Kbc9UAWjX++ToL4="
ota:
- platform: esphome
password: "000f95472f1adc514d45abdc280a3903"
#wifi:
# ssid: !secret wifi_ssid
# password: !secret wifi_password
#
# ap:
# ssid: "Main-Hub Fallback Hotspot"
# password: "y0LGSOyT5tjJ"
#captive_portal:
web_server:
port: 80
ethernet:
type: LAN8720
mdc_pin: GPIO23
mdio_pin: GPIO18
clk_mode: GPIO17_OUT
phy_addr: 0
power_pin: GPIO12
button:
- platform: restart
name: "main-hub Restart"
packages:
i2c: !include packages/i2c.yaml
pca9685: !include packages/pca9685.yaml
mcp23017_outputs: !include packages/mcp23017_outputs.yaml
mcp23017_inputs: !include packages/mcp23017_inputs.yaml
scripts: !include packages/scripts.yaml
esp32_ble_tracker:
sensor:
- platform: uptime
type: seconds
name: Uptime Sensor
- platform: atc_mithermometer
mac_address: "A4:C1:38:C5:11:58"
temperature:
name: "Biuro Temperatura"
humidity:
name: "Biuro Nawilżenie"
battery_level:
name: "Biuro Bateria"
- platform: atc_mithermometer
mac_address: "A4:C1:38:5C:4F:38"
temperature:
name: "Serwer Temperatura"
humidity:
name: "Serwer Nawilżenie"
battery_level:
name: "Serwer Bateria"
- platform: atc_mithermometer
mac_address: "A4:C1:38:67:6B:94"
temperature:
name: "Kuchnia Temperatura"
humidity:
name: "Kuchnia Nawilżenie"
battery_level:
name: "Kuchnia Bateria"
- platform: atc_mithermometer
mac_address: "A4:C1:38:C9:FE:CA"
temperature:
name: "WC Temperatura"
humidity:
name: "WC Nawilżenie"
battery_level:
name: "WC Bateria"
- platform: atc_mithermometer
mac_address: "A4:C1:38:63:6D:DB"
temperature:
name: "Łazienka Temperatura"
humidity:
name: "Łazienka Nawilżenie"
battery_level:
name: "Łazienka Bateria"

View File

@@ -0,0 +1,5 @@
i2c:
sda: 13
scl: 16
scan: True
frequency: 200kHz

View File

@@ -0,0 +1,884 @@
mcp23017:
- id: "mcp23xxx_hub1_IN"
address: 0x20
- id: "mcp23xxx_hub2_IN"
address: 0x22
- id: "mcp23xxx_hub3_IN"
address: 0x24
binary_sensor:
##################
#mcp23xxx_hub1_IN#
##################
# Wejście 1 — single: halogeny wejście, triple: wyłącz lampy, long: wyłącz wentylatory
- platform: gpio
name: "hub1_in0"
id: hub1_in0
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 0
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out15
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- script.execute: off_all_lights
- timing:
- ON for at least 350ms
then:
- script.execute: off_all_fans
# Wejście 3 — brak automatyzacji w Node-RED
- platform: gpio
name: "hub1_in1"
id: hub1_in1
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 1
mode: INPUT
inverted: True
# Wejście 2 — single: hub1_out14, triple: WLED korytarz kinkiety
- platform: gpio
name: "hub1_in2"
id: hub1_in2
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 2
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out14
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- script.execute: wled_korytarz_kinkiety_toggle
# Sypialnia 3 — single: turn_on hub2_out12+hub2_out1, double: toggle hub2_out12+hub2_out1
- platform: gpio
name: "hub1_in3"
id: hub1_in3
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 3
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.turn_on: hub2_out12
- light.turn_on: hub2_out1
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out12
- light.toggle: hub2_out1
# Sypialnia 1 — single: sypialnia LED
- platform: gpio
name: "hub1_in4"
id: hub1_in4
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 4
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: sypialnia_led
# Sypialnia 2 — single: hub2_out7
- platform: gpio
name: "hub1_in5"
id: hub1_in5
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 5
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out7
# Sypialnia Łóżko Prawa Strona — single: hub2_out1, double: hub2_out12+hub2_out1, long: wyłącz wszystkie
- platform: gpio
name: "hub1_in6"
id: hub1_in6
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 6
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out1
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out12
- light.toggle: hub2_out1
- timing:
- ON for at least 350ms
then:
- script.execute: off_all_lights
- script.execute: off_all_fans
# Sypialnia Łóżko Lewa Strona — single: hub2_out12, double: hub2_out12+hub2_out1, long: wyłącz wszystkie
- platform: gpio
name: "hub1_in7"
id: hub1_in7
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 7
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out12
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out12
- light.toggle: hub2_out1
- timing:
- ON for at least 350ms
then:
- script.execute: off_all_lights
- script.execute: off_all_fans
# Niebieski Pokój Lewy — single: hub2_out10, double: hub1_out7
- platform: gpio
name: "hub1_in8"
id: hub1_in8
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 8
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out10
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out7
# Niebieski Pokój Prawy — bezpośredni toggle pokój dla gości LED (bez gestów)
- platform: gpio
name: "hub1_in9"
id: hub1_in9
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 9
mode: INPUT
inverted: True
on_click:
min_length: 50ms
max_length: 1000ms
then:
- light.toggle: pokoj_dla_gosci_led
# Nie podpięte
- platform: gpio
name: "hub1_in10"
id: hub1_in10
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 10
mode: INPUT
inverted: True
- platform: gpio
name: "hub1_in11"
id: hub1_in11
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 11
mode: INPUT
inverted: True
- platform: gpio
name: "hub1_in12"
id: hub1_in12
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 12
mode: INPUT
inverted: True
- platform: gpio
name: "hub1_in13"
id: hub1_in13
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 13
mode: INPUT
inverted: True
- platform: gpio
name: "hub1_in14"
id: hub1_in14
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 14
mode: INPUT
inverted: True
- platform: gpio
name: "hub1_in15"
id: hub1_in15
pin:
mcp23xxx: mcp23xxx_hub1_IN
number: 15
mode: INPUT
inverted: True
##################
#mcp23xxx_hub2_IN#
##################
# Korytarz 2 — single: hub1_out15
- platform: gpio
name: "hub2_in0"
id: hub2_in0
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 0
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out15
# Korytarz 3 — single: WLED korytarz kinkiety, long: wyłącz wszystkie
- platform: gpio
name: "hub2_in1"
id: hub2_in1
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 1
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- script.execute: wled_korytarz_kinkiety_toggle
- timing:
- ON for at least 350ms
then:
- script.execute: off_all_lights
- script.execute: off_all_fans
# Korytarz 1 — single: hub1_out14, long: wyłącz wszystkie
- platform: gpio
name: "hub2_in2"
id: hub2_in2
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 2
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out14
- timing:
- ON for at least 350ms
then:
- script.execute: off_all_lights
- script.execute: off_all_fans
# Mały Korytarz — brak automatyzacji w Node-RED
- platform: gpio
name: "hub2_in3"
id: hub2_in3
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 3
mode: INPUT
inverted: True
# Garderoba Mała — single: hub2_out14, double: hub3_out2 (wentylator)
- platform: gpio
name: "hub2_in4"
id: hub2_in4
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 4
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out14
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub3_out2
# Kuchnia 3_1 — single: hub1_out8
- platform: gpio
name: "hub2_in5"
id: hub2_in5
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 5
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out8
# Kuchnia 3_2 — single: jadalnia LED
- platform: gpio
name: "hub2_in6"
id: hub2_in6
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 6
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: jadalnia_led
# Kuchnia 2_1 — single: hub1_out3, triple: wyłącz lampy, long: wyłącz wentylatory
- platform: gpio
name: "hub2_in7"
id: hub2_in7
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 7
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out3
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- script.execute: off_all_lights
- timing:
- ON for at least 350ms
then:
- script.execute: off_all_fans
# Kuchnia 2_2 — single: kuchnia łezka LED
- platform: gpio
name: "hub2_in8"
id: hub2_in8
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 8
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: kuchnia_lezka_led
# Kuchnia 1_1 — single: hub2_out11, double: WLED salon wentylator
- platform: gpio
name: "hub2_in9"
id: hub2_in9
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 9
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out11
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- script.execute: wled_salon_wentylator_toggle
# Kuchnia 1_2 — single: WLED komin+salon kinkiety toggle, double: WLED komin on, long: WLED komin on
- platform: gpio
name: "hub2_in10"
id: hub2_in10
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 10
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- script.execute: wled_komin_toggle
- script.execute: wled_salon_kinkiety_toggle
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- script.execute: wled_komin_on
- timing:
- ON for at least 350ms
then:
- script.execute: wled_komin_on
# Schody 3 — single: WLED oczka taras + skrzynia + tunel + zwis schody toggle
- platform: gpio
name: "hub2_in11"
id: hub2_in11
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 11
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- script.execute: wled_schody_all_toggle
# Schody 1 — single: hub2_out11
- platform: gpio
name: "hub2_in12"
id: hub2_in12
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 12
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out11
# Schody 2 — single: WLED salon kinkiety toggle, double: WLED salon kinkiety off, long: WLED komin toggle
- platform: gpio
name: "hub2_in13"
id: hub2_in13
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 13
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- script.execute: wled_salon_kinkiety_toggle
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- script.execute: wled_salon_kinkiety_off
- timing:
- ON for at least 350ms
then:
- script.execute: wled_komin_toggle
# Nie podpięte
- platform: gpio
name: "hub2_in14"
id: hub2_in14
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 14
mode: INPUT
inverted: True
- platform: gpio
name: "hub2_in15"
id: hub2_in15
pin:
mcp23xxx: mcp23xxx_hub2_IN
number: 15
mode: INPUT
inverted: True
##################
#mcp23xxx_hub3_IN#
##################
# Garderoba Duża Lewy — single: hub1_out7
- platform: gpio
name: "hub3_in0"
id: hub3_in0
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 0
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out7
# Garderoba Duża Prawy — single: hub1_out7
- platform: gpio
name: "hub3_in1"
id: hub3_in1
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 1
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out7
# SPA Lewy — single: hub2_out13, triple: hub1_out4 (wentylator)
- platform: gpio
name: "hub3_in2"
id: hub3_in2
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 2
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out13
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out4
# SPA Prawy — single: hub1_out4+hub1_out10, triple: SPA Ledy
- platform: gpio
name: "hub3_in3"
id: hub3_in3
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 3
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out4
- light.toggle: hub1_out10
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: spa_ledy
# Biała Łazienka Ściana — single: hub2_out0, double: biała łazienka wanna LED, long: hub2_out8 (wentylator)
- platform: gpio
name: "hub3_in4"
id: hub3_in4
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 4
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out0
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: biala_lazienka_wanna_led
- timing:
- ON for at least 350ms
then:
- light.toggle: hub2_out8
# Małe WC Lustro Prawy — bezpośredni toggle hub2_out2 + WLED wc_led + hub2_out5 (wentylator)
- platform: gpio
name: "hub3_in5"
id: hub3_in5
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 5
mode: INPUT
inverted: True
on_click:
min_length: 50ms
max_length: 1000ms
then:
- light.toggle: hub2_out2
- light.toggle: hub2_out5
- script.execute: wled_wc_led_toggle
# Małe WC Lustro Lewy — bezpośredni toggle hub1_out9
- platform: gpio
name: "hub3_in6"
id: hub3_in6
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 6
mode: INPUT
inverted: True
on_click:
min_length: 50ms
max_length: 1000ms
then:
- light.toggle: hub1_out9
# Biała Łazienka Skos — single: hub2_out4
- platform: gpio
name: "hub3_in7"
id: hub3_in7
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 7
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub2_out4
# SPA Lustro Lewy — single: hub1_out5
- platform: gpio
name: "hub3_in8"
id: hub3_in8
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 8
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out5
# SPA Lustro Prawy — single: hub1_out1
- platform: gpio
name: "hub3_in9"
id: hub3_in9
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 9
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out1
# Biuro Skos — single: hub1_out2, double: serwer LED wyłącz
- platform: gpio
name: "hub3_in10"
id: hub3_in10
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 10
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out2
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.turn_off: serwer_led
# Serwer Prawy — single: serwer LED, double: hub1_out2, triple: hub3_out2 (wentylator)
- platform: gpio
name: "hub3_in11"
id: hub3_in11
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 11
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: serwer_led
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out2
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub3_out2
# Serwer Lewy — single: hub1_out14, triple: wyłącz lampy, long: wyłącz wentylatory
- platform: gpio
name: "hub3_in12"
id: hub3_in12
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 12
mode: INPUT
inverted: True
on_multi_click:
- timing:
- ON for at most 350ms
- OFF for at least 350ms
then:
- light.toggle: hub1_out14
- timing:
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at most 350ms
- ON for at most 350ms
- OFF for at least 350ms
then:
- script.execute: off_all_lights
- timing:
- ON for at least 350ms
then:
- script.execute: off_all_fans
# Nie podpięte
- platform: gpio
name: "hub3_in13"
id: hub3_in13
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 13
mode: INPUT
inverted: True
- platform: gpio
name: "hub3_in14"
id: hub3_in14
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 14
mode: INPUT
inverted: True
- platform: gpio
name: "hub3_in15"
id: hub3_in15
pin:
mcp23xxx: mcp23xxx_hub3_IN
number: 15
mode: INPUT
inverted: True

View File

@@ -0,0 +1,407 @@
mcp23017:
- id: "mcp23xxx_hub1_OUT"
address: 0x21
- id: "mcp23xxx_hub2_OUT"
address: 0x23
- id: "mcp23xxx_hub3_OUT"
address: 0x25
output:
# MCP23017 GPIO outputs — hub1
- platform: gpio
id: hub1_out0_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 0, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out1_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 1, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out2_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 2, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out3_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 3, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out4_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 4, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out5_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 5, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out6_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 6, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out7_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 7, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out8_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 8, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out9_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 9, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out10_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 10, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out11_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 11, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out12_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 12, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out13_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 13, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out14_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 14, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub1_out15_gpio
pin: {mcp23xxx: mcp23xxx_hub1_OUT, number: 15, mode: OUTPUT, inverted: False}
# MCP23017 GPIO outputs — hub2
- platform: gpio
id: hub2_out0_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 0, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out1_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 1, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out2_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 2, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out3_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 3, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out4_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 4, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out5_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 5, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out6_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 6, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out7_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 7, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out8_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 8, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out9_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 9, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out10_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 10, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out11_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 11, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out12_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 12, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out13_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 13, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out14_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 14, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub2_out15_gpio
pin: {mcp23xxx: mcp23xxx_hub2_OUT, number: 15, mode: OUTPUT, inverted: False}
# MCP23017 GPIO outputs — hub3
- platform: gpio
id: hub3_out0_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 0, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out1_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 1, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out2_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 2, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out3_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 3, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out4_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 4, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out5_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 5, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out6_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 6, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out7_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 7, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out8_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 8, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out9_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 9, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out10_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 10, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out11_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 11, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out12_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 12, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out13_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 13, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out14_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 14, mode: OUTPUT, inverted: False}
- platform: gpio
id: hub3_out15_gpio
pin: {mcp23xxx: mcp23xxx_hub3_OUT, number: 15, mode: OUTPUT, inverted: False}
light:
# hub1 outputs
- platform: binary
name: "hub1_out0"
id: hub1_out0
output: hub1_out0_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out1"
id: hub1_out1
output: hub1_out1_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out2"
id: hub1_out2
output: hub1_out2_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out3"
id: hub1_out3
output: hub1_out3_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out4" # Fan: SPA/Łazienka
id: hub1_out4
output: hub1_out4_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out5"
id: hub1_out5
output: hub1_out5_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out6"
id: hub1_out6
output: hub1_out6_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out7"
id: hub1_out7
output: hub1_out7_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out8"
id: hub1_out8
output: hub1_out8_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out9"
id: hub1_out9
output: hub1_out9_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out10"
id: hub1_out10
output: hub1_out10_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out11"
id: hub1_out11
output: hub1_out11_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out12"
id: hub1_out12
output: hub1_out12_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out13"
id: hub1_out13
output: hub1_out13_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out14"
id: hub1_out14
output: hub1_out14_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub1_out15"
id: hub1_out15
output: hub1_out15_gpio
restore_mode: ALWAYS_OFF
# hub2 outputs
- platform: binary
name: "hub2_out0"
id: hub2_out0
output: hub2_out0_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out1"
id: hub2_out1
output: hub2_out1_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out2"
id: hub2_out2
output: hub2_out2_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out3"
id: hub2_out3
output: hub2_out3_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out4"
id: hub2_out4
output: hub2_out4_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out5" # Fan: Małe WC
id: hub2_out5
output: hub2_out5_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out6"
id: hub2_out6
output: hub2_out6_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out7"
id: hub2_out7
output: hub2_out7_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out8" # Fan: Biała Łazienka
id: hub2_out8
output: hub2_out8_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out9"
id: hub2_out9
output: hub2_out9_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out10"
id: hub2_out10
output: hub2_out10_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out11"
id: hub2_out11
output: hub2_out11_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out12"
id: hub2_out12
output: hub2_out12_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out13"
id: hub2_out13
output: hub2_out13_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out14"
id: hub2_out14
output: hub2_out14_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub2_out15"
id: hub2_out15
output: hub2_out15_gpio
restore_mode: ALWAYS_OFF
# hub3 outputs
- platform: binary
name: "hub3_out0"
id: hub3_out0
output: hub3_out0_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out1"
id: hub3_out1
output: hub3_out1_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out2" # Fan: Biuro/Serwer
id: hub3_out2
output: hub3_out2_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out3"
id: hub3_out3
output: hub3_out3_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out4"
id: hub3_out4
output: hub3_out4_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out5"
id: hub3_out5
output: hub3_out5_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out6"
id: hub3_out6
output: hub3_out6_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out7"
id: hub3_out7
output: hub3_out7_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out8"
id: hub3_out8
output: hub3_out8_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out9"
id: hub3_out9
output: hub3_out9_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out10"
id: hub3_out10
output: hub3_out10_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out11"
id: hub3_out11
output: hub3_out11_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out12"
id: hub3_out12
output: hub3_out12_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out13"
id: hub3_out13
output: hub3_out13_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out14"
id: hub3_out14
output: hub3_out14_gpio
restore_mode: ALWAYS_OFF
- platform: binary
name: "hub3_out15"
id: hub3_out15
output: hub3_out15_gpio
restore_mode: ALWAYS_OFF

View File

@@ -0,0 +1,221 @@
pca9685:
- frequency: 1500
address: 0x40
id: "pca9685_hub1"
- frequency: 1500
address: 0x41
id: "pca9685_hub2"
output:
# Status RGB LED (ledc)
- platform: ledc
id: output_blue
pin: 5
- platform: ledc
id: output_green
pin: 14
- platform: ledc
id: output_red
pin: 15
# PCA9685 Hub1 — kanały PWM
- platform: pca9685
id: 'PWM1_1_Hub1'
pca9685_id: 'pca9685_hub1'
channel: 0
max_power: 75%
- platform: pca9685
id: 'PWM1_2_Hub1'
pca9685_id: 'pca9685_hub1'
channel: 1
max_power: 75%
- platform: pca9685
id: 'PWM2_1_Hub1'
pca9685_id: 'pca9685_hub1'
channel: 2
max_power: 75%
- platform: pca9685
id: 'PWM2_2_Hub1'
pca9685_id: 'pca9685_hub1'
channel: 3
max_power: 75%
- platform: pca9685
id: 'PWM3_1_Hub1'
pca9685_id: 'pca9685_hub1'
channel: 4
max_power: 75%
- platform: pca9685
id: 'PWM3_2_Hub1'
pca9685_id: 'pca9685_hub1'
channel: 8
max_power: 75%
- platform: pca9685
id: 'PWM4_1_Hub1'
pca9685_id: 'pca9685_hub1'
channel: 6
min_power: 0%
max_power: 75%
- platform: pca9685
id: 'PWM4_2_Hub1'
pca9685_id: 'pca9685_hub1'
channel: 7
min_power: 0%
max_power: 75%
- platform: pca9685
id: 'PWM5_1_Hub1'
pca9685_id: 'pca9685_hub1'
channel: 12
max_power: 75%
- platform: pca9685
id: 'PWM5_2_Hub1'
pca9685_id: 'pca9685_hub1'
channel: 13
max_power: 75%
# PCA9685 Hub2 — kanały PWM
- platform: pca9685
id: 'PWM1_1_Hub2'
pca9685_id: 'pca9685_hub2'
channel: 0
max_power: 75%
- platform: pca9685
id: 'PWM1_2_Hub2'
pca9685_id: 'pca9685_hub2'
channel: 1
max_power: 75%
- platform: pca9685
id: 'PWM2_1_Hub2'
pca9685_id: 'pca9685_hub2'
channel: 2
max_power: 75%
- platform: pca9685
id: 'PWM2_2_Hub2'
pca9685_id: 'pca9685_hub2'
channel: 3
max_power: 75%
- platform: pca9685
id: 'PWM3_1_Hub2'
pca9685_id: 'pca9685_hub2'
channel: 4
max_power: 75%
- platform: pca9685
id: 'PWM3_2_Hub2'
pca9685_id: 'pca9685_hub2'
channel: 5
max_power: 95%
- platform: pca9685
id: 'PWM4_1_Hub2'
pca9685_id: 'pca9685_hub2'
channel: 10
max_power: 95%
- platform: pca9685
id: 'PWM4_2_Hub2'
pca9685_id: 'pca9685_hub2'
channel: 13
max_power: 75%
- platform: pca9685
id: 'PWM5_1_Hub2'
pca9685_id: 'pca9685_hub2'
channel: 11
max_power: 75%
- platform: pca9685
id: 'PWM5_2_Hub2'
pca9685_id: 'pca9685_hub2'
channel: 15
max_power: 75%
light:
- platform: rgb
name: "Status LED"
red: output_red
green: output_green
blue: output_blue
effects:
- pulse:
name: "Fast Pulse"
transition_length: 0.5s
update_interval: 0.5s
- pulse:
name: "Slow Pulse"
transition_length: 1s
update_interval: 1s
- random:
name: "Random Effect"
transition_length: 1s
update_interval: 2s
- platform: cwww
name: "Serwer LED"
id: serwer_led
cold_white: PWM2_1_Hub1
warm_white: PWM2_2_Hub1
cold_white_color_temperature: 6536 K
warm_white_color_temperature: 2000 K
default_transition_length: 2.0s
- platform: cwww
name: "Jadalnia LED"
id: jadalnia_led
cold_white: PWM4_2_Hub1
warm_white: PWM4_1_Hub1
cold_white_color_temperature: 6536 K
warm_white_color_temperature: 2000 K
default_transition_length: 2.0s
- platform: cwww
name: "Biała Łazienka wanna LED"
id: biala_lazienka_wanna_led
cold_white: PWM5_2_Hub1
warm_white: PWM5_1_Hub1
cold_white_color_temperature: 6536 K
warm_white_color_temperature: 2000 K
default_transition_length: 2.0s
- platform: monochromatic
name: "Kuchnia Łezka LED"
id: kuchnia_lezka_led
output: PWM2_1_Hub2
default_transition_length: 2.0s
- platform: cwww
name: "SPA Ledy"
id: spa_ledy
cold_white: PWM5_2_Hub2
warm_white: PWM5_1_Hub2
cold_white_color_temperature: 6536 K
warm_white_color_temperature: 2000 K
default_transition_length: 2.0s
- platform: cwww
name: "Pokój dla gości LED"
id: pokoj_dla_gosci_led
cold_white: PWM3_2_Hub2
warm_white: PWM4_1_Hub2
cold_white_color_temperature: 6536 K
warm_white_color_temperature: 2000 K
default_transition_length: 2.0s
- platform: monochromatic
name: "TEST LED 1"
id: test_led_1
output: PWM1_2_Hub2
- platform: monochromatic
name: "TEST LED 2"
id: test_led_2
output: PWM3_1_Hub2
- platform: rgbww
name: "Sypialnia LED"
id: sypialnia_led
red: PWM1_1_Hub1
green: PWM1_2_Hub1
blue: PWM3_1_Hub1
cold_white: PWM3_2_Hub1
warm_white: PWM1_1_Hub2
cold_white_color_temperature: 6536 K
warm_white_color_temperature: 2000 K
color_interlock: false
default_transition_length: 2.0s

View File

@@ -0,0 +1,114 @@
script:
# ── Wyłącz wszystkie lampy ───────────────────────────────────────────────
- id: off_all_lights
then:
- light.turn_off: serwer_led
- light.turn_off: jadalnia_led
- light.turn_off: biala_lazienka_wanna_led
- light.turn_off: kuchnia_lezka_led
- light.turn_off: spa_ledy
- light.turn_off: pokoj_dla_gosci_led
- light.turn_off: test_led_1
- light.turn_off: test_led_2
- light.turn_off: sypialnia_led
- light.turn_off: hub1_out0
- light.turn_off: hub1_out1
- light.turn_off: hub1_out2
- light.turn_off: hub1_out3
- light.turn_off: hub1_out5
- light.turn_off: hub1_out6
- light.turn_off: hub1_out7
- light.turn_off: hub1_out8
- light.turn_off: hub1_out9
- light.turn_off: hub1_out10
- light.turn_off: hub1_out11
- light.turn_off: hub1_out12
- light.turn_off: hub1_out13
- light.turn_off: hub1_out14
- light.turn_off: hub1_out15
- light.turn_off: hub2_out0
- light.turn_off: hub2_out1
- light.turn_off: hub2_out2
- light.turn_off: hub2_out3
- light.turn_off: hub2_out4
- light.turn_off: hub2_out6
- light.turn_off: hub2_out7
- light.turn_off: hub2_out9
- light.turn_off: hub2_out10
- light.turn_off: hub2_out11
- light.turn_off: hub2_out12
- light.turn_off: hub2_out13
- light.turn_off: hub2_out14
- light.turn_off: hub2_out15
- light.turn_off: hub3_out0
- light.turn_off: hub3_out1
- light.turn_off: hub3_out3
- light.turn_off: hub3_out4
- light.turn_off: hub3_out5
- light.turn_off: hub3_out6
- light.turn_off: hub3_out7
- light.turn_off: hub3_out8
- light.turn_off: hub3_out9
- light.turn_off: hub3_out10
- light.turn_off: hub3_out11
- light.turn_off: hub3_out12
- light.turn_off: hub3_out13
- light.turn_off: hub3_out14
- light.turn_off: hub3_out15
# ── Wyłącz wszystkie wentylatory ─────────────────────────────────────────
- id: off_all_fans
then:
- light.turn_off: hub3_out2 # Fan: Biuro/Serwer
- light.turn_off: hub1_out4 # Fan: SPA/Łazienka
- light.turn_off: hub2_out5 # Fan: Małe WC
- light.turn_off: hub2_out8 # Fan: Biała Łazienka
# ── WLED placeholders (TODO: zastąpić sterowaniem WLED przez HA/MQTT) ───
- id: wled_korytarz_kinkiety_toggle
then:
- logger.log:
level: INFO
format: "WLED PLACEHOLDER: korytarz_kinkiety toggle"
- id: wled_komin_toggle
then:
- logger.log:
level: INFO
format: "WLED PLACEHOLDER: komin_led toggle"
- id: wled_komin_on
then:
- logger.log:
level: INFO
format: "WLED PLACEHOLDER: komin_led turn_on"
- id: wled_salon_kinkiety_toggle
then:
- logger.log:
level: INFO
format: "WLED PLACEHOLDER: salon_kinkiety_led toggle"
- id: wled_salon_kinkiety_off
then:
- logger.log:
level: INFO
format: "WLED PLACEHOLDER: salon_kinkiety_led turn_off"
- id: wled_schody_all_toggle
then:
- logger.log:
level: INFO
format: "WLED PLACEHOLDER: oczka_taras + skrzynia + tunel + zwis_schody toggle"
- id: wled_wc_led_toggle
then:
- logger.log:
level: INFO
format: "WLED PLACEHOLDER: wc_led toggle"
- id: wled_salon_wentylator_toggle
then:
- logger.log:
level: INFO
format: "WLED PLACEHOLDER: salon_wentylator toggle"

View File

@@ -3,6 +3,8 @@ substitutions:
friendly_name: 'BoneIO ESP 32x10 Switches'
serial_prefix: 'esp' #Don't change it.
# dodać https://github.com/ethanak/SimplePgSQL i zaczytywac dane z postgressa a nie z Home Assistanta co do karty, pinu i meldunek
esphome:
name: '${name}'
friendly_name: '${friendly_name}'

View File

@@ -0,0 +1,3 @@
# Skopiuj do .env i uzupełnij
VITE_HA_URL=http://10.20.10.106:8123
VITE_APARTMENTS=7

View File

@@ -0,0 +1,224 @@
# ============================================================
# Home Assistant — helpery dla Panelu Dostępu
# ============================================================
# Wklej do configuration.yaml lub do osobnych plików przez split config.
# Po dodaniu: Ustawienia → Serwer → Uruchom ponownie HA.
# ============================================================
input_number:
apartament_1:
name: "Apartament 1 — PIN"
min: 0
max: 99999999
step: 1
mode: box
icon: mdi:lock
apartament_2:
name: "Apartament 2 — PIN"
min: 0
max: 99999999
step: 1
mode: box
icon: mdi:lock
apartament_3:
name: "Apartament 3 — PIN"
min: 0
max: 99999999
step: 1
mode: box
icon: mdi:lock
apartament_4:
name: "Apartament 4 — PIN"
min: 0
max: 99999999
step: 1
mode: box
icon: mdi:lock
apartament_5:
name: "Apartament 5 — PIN"
min: 0
max: 99999999
step: 1
mode: box
icon: mdi:lock
apartament_6:
name: "Apartament 6 — PIN"
min: 0
max: 99999999
step: 1
mode: box
icon: mdi:lock
apartament_7:
name: "Apartament 7 — PIN"
min: 0
max: 99999999
step: 1
mode: box
icon: mdi:lock
# ============================================================
input_text:
apartament_karta_1:
name: "Apartament 1 — Karta"
max: 20
icon: mdi:card-account-details
apartament_karta_2:
name: "Apartament 2 — Karta"
max: 20
icon: mdi:card-account-details
apartament_karta_3:
name: "Apartament 3 — Karta"
max: 20
icon: mdi:card-account-details
apartament_karta_4:
name: "Apartament 4 — Karta"
max: 20
icon: mdi:card-account-details
apartament_karta_5:
name: "Apartament 5 — Karta"
max: 20
icon: mdi:card-account-details
apartament_karta_6:
name: "Apartament 6 — Karta"
max: 20
icon: mdi:card-account-details
apartament_karta_7:
name: "Apartament 7 — Karta"
max: 20
icon: mdi:card-account-details
# ============================================================
input_datetime:
apartament_1_zameldowanie:
name: "Apartament 1 — Zameldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-right
apartament_1_wymeldowanie:
name: "Apartament 1 — Wymeldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-left
apartament_2_zameldowanie:
name: "Apartament 2 — Zameldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-right
apartament_2_wymeldowanie:
name: "Apartament 2 — Wymeldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-left
apartament_3_zameldowanie:
name: "Apartament 3 — Zameldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-right
apartament_3_wymeldowanie:
name: "Apartament 3 — Wymeldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-left
apartament_4_zameldowanie:
name: "Apartament 4 — Zameldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-right
apartament_4_wymeldowanie:
name: "Apartament 4 — Wymeldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-left
apartament_5_zameldowanie:
name: "Apartament 5 — Zameldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-right
apartament_5_wymeldowanie:
name: "Apartament 5 — Wymeldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-left
apartament_6_zameldowanie:
name: "Apartament 6 — Zameldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-right
apartament_6_wymeldowanie:
name: "Apartament 6 — Wymeldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-left
apartament_7_zameldowanie:
name: "Apartament 7 — Zameldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-right
apartament_7_wymeldowanie:
name: "Apartament 7 — Wymeldowanie"
has_date: true
has_time: true
icon: mdi:calendar-arrow-left
# ============================================================
# input_boolean — flaga "nowy gość" dla automatyzacji świateł
# on = gość jeszcze nie wszedł → automatyzacja odpali się przy pierwszym wejściu
# off = gość już wszedł → automatyzacja nie odpali się ponownie
# Reset przez panel przy meldowaniu nowego gościa.
# ============================================================
input_boolean:
apartament_1_nowy_gosc:
name: "Apartament 1 — Nowy gość"
icon: mdi:account-arrow-right
apartament_2_nowy_gosc:
name: "Apartament 2 — Nowy gość"
icon: mdi:account-arrow-right
apartament_3_nowy_gosc:
name: "Apartament 3 — Nowy gość"
icon: mdi:account-arrow-right
apartament_4_nowy_gosc:
name: "Apartament 4 — Nowy gość"
icon: mdi:account-arrow-right
apartament_5_nowy_gosc:
name: "Apartament 5 — Nowy gość"
icon: mdi:account-arrow-right
apartament_6_nowy_gosc:
name: "Apartament 6 — Nowy gość"
icon: mdi:account-arrow-right
apartament_7_nowy_gosc:
name: "Apartament 7 — Nowy gość"
icon: mdi:account-arrow-right

18
panel-dostepu/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Panel Dostępu</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600&family=Inter:wght@300;400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1246
panel-dostepu/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "panel-dostepu",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"start": "npm run build && npm run preview"
},
"dependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
}

129
panel-dostepu/src/App.vue Normal file
View File

@@ -0,0 +1,129 @@
<template>
<LoginView v-if="!authed" @authenticated="onAuthenticated" />
<template v-else>
<DashboardView
v-if="selectedApt === null"
:token="token"
@logout="onLogout"
@select="selectedApt = $event"
/>
<ApartmentView
v-else
:nr="selectedApt"
:token="token"
@back="selectedApt = null"
@logout="onLogout"
/>
<!-- Ostrzeżenie przed wylogowaniem -->
<transition name="fade-warn">
<div v-if="showWarning" class="timeout-warning">
Brak aktywności wylogowanie za {{ warningCountdown }} s
</div>
</transition>
</template>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import LoginView from './views/LoginView.vue'
import DashboardView from './views/DashboardView.vue'
import ApartmentView from './views/ApartmentView.vue'
import { loadToken, isAuthed, panelLogout } from './services/auth.js'
const TIMEOUT_MS = 2 * 60 * 1000 // 2 minuty bezczynności
const WARN_BEFORE = 30 * 1000 // ostrzeżenie 30 s przed końcem
const token = ref(loadToken())
const authed = ref(isAuthed())
const selectedApt = ref(null)
// ── Timeout ────────────────────────────────────────────────
const showWarning = ref(false)
const warningCountdown = ref(30)
let idleTimer = null
let warnTimer = null
let countdownInt = null
function resetTimers() {
if (!authed.value) return
clearTimeout(idleTimer)
clearTimeout(warnTimer)
clearInterval(countdownInt)
showWarning.value = false
// Ostrzeżenie po (TIMEOUT_MS - WARN_BEFORE)
warnTimer = setTimeout(() => {
showWarning.value = true
warningCountdown.value = WARN_BEFORE / 1000
countdownInt = setInterval(() => {
warningCountdown.value--
if (warningCountdown.value <= 0) clearInterval(countdownInt)
}, 1000)
}, TIMEOUT_MS - WARN_BEFORE)
// Wylogowanie po TIMEOUT_MS
idleTimer = setTimeout(() => {
onLogout()
}, TIMEOUT_MS)
}
function clearAllTimers() {
clearTimeout(idleTimer)
clearTimeout(warnTimer)
clearInterval(countdownInt)
showWarning.value = false
}
const EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll', 'click']
function bindEvents() { EVENTS.forEach(e => window.addEventListener(e, resetTimers, { passive: true })) }
function unbindEvents() { EVENTS.forEach(e => window.removeEventListener(e, resetTimers)) }
// Uruchom/zatrzymaj timer gdy zmienia się stan logowania
watch(authed, (val) => {
if (val) { bindEvents(); resetTimers() }
else { unbindEvents(); clearAllTimers() }
}, { immediate: true })
onUnmounted(() => { unbindEvents(); clearAllTimers() })
// ── Auth ───────────────────────────────────────────────────
function onAuthenticated() {
authed.value = true
}
function onLogout() {
panelLogout()
authed.value = false
selectedApt.value = null
}
</script>
<style>
.timeout-warning {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: #7a2a2a;
color: #f0d0d0;
font-family: var(--font-sans);
font-size: 13px;
padding: 10px 24px;
border-radius: 6px;
border: 1px solid rgba(200, 100, 100, 0.3);
z-index: 9999;
white-space: nowrap;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
}
.fade-warn-enter-active, .fade-warn-leave-active {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.fade-warn-enter-from, .fade-warn-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(12px);
}
</style>

View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

View File

@@ -0,0 +1,46 @@
const STORAGE_KEY = 'ha_token'
const SESSION_KEY = 'panel_authed'
const PRESET_TOKEN = import.meta.env.VITE_HA_TOKEN || ''
const VALID_LOGIN = import.meta.env.VITE_LOGIN || ''
const VALID_PASS = import.meta.env.VITE_PASSWORD || ''
export function loadToken() {
if (PRESET_TOKEN) return PRESET_TOKEN
return localStorage.getItem(STORAGE_KEY) || ''
}
export function saveToken(token) {
localStorage.setItem(STORAGE_KEY, token)
}
export function clearToken() {
localStorage.removeItem(STORAGE_KEY)
}
/** Sprawdza czy użytkownik jest zalogowany do panelu */
export function isAuthed() {
return sessionStorage.getItem(SESSION_KEY) === '1'
}
/**
* Loguje do panelu (login/hasło z .env).
* Zwraca true jeśli OK, false jeśli błędne dane.
*/
export function panelLogin(username, password) {
if (username === VALID_LOGIN && password === VALID_PASS) {
sessionStorage.setItem(SESSION_KEY, '1')
return true
}
return false
}
export function panelLogout() {
sessionStorage.removeItem(SESSION_KEY)
}
export async function validateToken(token) {
const res = await fetch('/api/', {
headers: { 'Authorization': `Bearer ${token}` },
})
return res.ok
}

View File

@@ -0,0 +1,128 @@
async function callService(token, domain, service, data) {
const res = await fetch(`/api/services/${domain}/${service}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`HTTP ${res.status}${text ? ': ' + text : ''}`)
}
}
export async function setPin(token, nr, pin) {
const value = Number(pin)
if (isNaN(value)) throw new Error('PIN musi być liczbą')
await callService(token, 'input_number', 'set_value', {
entity_id: `input_number.apartament_${nr}`,
value,
})
}
export async function setCard(token, nr, cardId) {
if (!cardId.trim()) throw new Error('Numer karty nie może być pusty')
await callService(token, 'input_text', 'set_value', {
entity_id: `input_text.apartament_karta_${nr}`,
value: cardId.trim(),
})
}
export async function setCheckin(token, nr, datetimeLocal) {
await callService(token, 'input_datetime', 'set_datetime', {
entity_id: `input_datetime.apartament_${nr}_zameldowanie`,
datetime: datetimeLocal.replace('T', ' ') + ':00',
})
}
export async function setCheckout(token, nr, datetimeLocal) {
await callService(token, 'input_datetime', 'set_datetime', {
entity_id: `input_datetime.apartament_${nr}_wymeldowanie`,
datetime: datetimeLocal.replace('T', ' ') + ':00',
})
}
async function getEntityData(token, entityId) {
const res = await fetch(`/api/states/${entityId}`, {
headers: { 'Authorization': `Bearer ${token}` },
})
if (!res.ok) throw new Error(`Nie można odczytać ${entityId} (HTTP ${res.status})`)
return await res.json()
}
async function getState(token, entityId) {
return (await getEntityData(token, entityId)).state
}
/**
* Ustawia flagę "nowy gość" (input_boolean.apartament_X_nowy_gosc).
* turn_on = nowy gość (automatyzacja aktywna)
* turn_off = reset (po pierwszym wejściu HA samo wywoła turn_off)
*/
export async function setNewGuest(token, nr, value = true) {
const service = value ? 'turn_on' : 'turn_off'
await callService(token, 'input_boolean', service, {
entity_id: `input_boolean.apartament_${nr}_nowy_gosc`,
})
}
/**
* Zwraca datę ostatniej zmiany wartości encji (last_changed).
* @returns {Date|null}
*/
export async function getLastChanged(token, entityId) {
try {
const data = await getEntityData(token, entityId)
return data.last_changed ? new Date(data.last_changed) : null
} catch {
return null
}
}
export async function verifyAll(token, nr, { checkin, checkout, pin, card }) {
const errors = []
const normalize = dt => dt.replace('T', ' ') + ':00'
const checks = []
if (checkin !== null) {
checks.push(
getState(token, `input_datetime.apartament_${nr}_zameldowanie`).then(s => {
if (s !== normalize(checkin))
errors.push(`Zameldowanie: HA ma "${s}", oczekiwano "${normalize(checkin)}"`)
})
)
}
if (checkout !== null) {
checks.push(
getState(token, `input_datetime.apartament_${nr}_wymeldowanie`).then(s => {
if (s !== normalize(checkout))
errors.push(`Wymeldowanie: HA ma "${s}", oczekiwano "${normalize(checkout)}"`)
})
)
}
if (pin !== null) {
checks.push(
getState(token, `input_number.apartament_${nr}`).then(s => {
if (Number(s) !== Number(pin))
errors.push(`PIN: HA ma "${s}", oczekiwano "${pin}"`)
})
)
}
if (card !== null) {
checks.push(
getState(token, `input_text.apartament_karta_${nr}`).then(s => {
if (s !== card.trim())
errors.push(`Karta: HA ma "${s}", oczekiwano "${card.trim()}"`)
})
)
}
await Promise.all(checks)
return errors
}

148
panel-dostepu/src/style.css Normal file
View File

@@ -0,0 +1,148 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0c0c0c;
--bg-card: #161616;
--bg-input: #1e1e1e;
--border: rgba(255, 255, 255, 0.07);
--border-focus: rgba(185, 150, 60, 0.5);
--gold: #b9963c;
--gold-light: #d4af6a;
--text: #ede9e3;
--text-muted: #6b6560;
--text-dim: #9e9890;
--success: #4a7c59;
--success-bg: rgba(74, 124, 89, 0.12);
--error: #8b3a3a;
--error-bg: rgba(139, 58, 58, 0.12);
--font-serif: 'Playfair Display', Georgia, serif;
--font-sans: 'Inter', system-ui, sans-serif;
--radius: 4px;
--radius-lg: 8px;
--transition: 200ms ease;
}
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
#app {
min-height: 100vh;
}
/* ── Typography ─────────────────────────────────────────── */
h1, h2, h3 {
font-family: var(--font-serif);
font-weight: 400;
letter-spacing: 0.02em;
}
/* ── Inputs ─────────────────────────────────────────────── */
input {
width: 100%;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font-sans);
font-size: 14px;
padding: 10px 14px;
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
}
input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(185, 150, 60, 0.08);
}
input::placeholder {
color: var(--text-muted);
}
/* ── Buttons ─────────────────────────────────────────────── */
button {
cursor: pointer;
font-family: var(--font-sans);
font-size: 13px;
letter-spacing: 0.06em;
text-transform: uppercase;
border: none;
border-radius: var(--radius);
padding: 10px 20px;
transition: all var(--transition);
}
.btn-primary {
background: var(--gold);
color: #0c0c0c;
font-weight: 500;
}
.btn-primary:hover {
background: var(--gold-light);
}
.btn-primary:active {
transform: translateY(1px);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.btn-ghost {
background: transparent;
color: var(--text-dim);
border: 1px solid var(--border);
}
.btn-ghost:hover {
border-color: rgba(255,255,255,0.15);
color: var(--text);
}
/* ── Divider ─────────────────────────────────────────────── */
.divider {
height: 1px;
background: var(--border);
margin: 20px 0;
}
/* ── Feedback ────────────────────────────────────────────── */
.feedback {
padding: 8px 12px;
border-radius: var(--radius);
font-size: 13px;
margin-top: 8px;
}
.feedback.success {
background: var(--success-bg);
color: #7eba94;
border: 1px solid rgba(74, 124, 89, 0.25);
}
.feedback.error {
background: var(--error-bg);
color: #c97a7a;
border: 1px solid rgba(139, 58, 58, 0.25);
}
/* ── Scrollbar ───────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #3a3a3a; }

View File

@@ -0,0 +1,763 @@
<template>
<div class="page">
<header class="topbar">
<div class="topbar-inner">
<div class="brand">
<span class="brand-mark">&#9632;</span>
<span class="brand-name">Panel Dostępu</span>
</div>
<button class="btn-ghost logout-btn" @click="$emit('logout')">Wyloguj</button>
</div>
</header>
<main class="content">
<button class="back-btn" @click="$emit('back')">
<span class="back-arrow">&#8592;</span> Powrót do listy
</button>
<div class="apt-heading">
<div class="apt-badge">{{ nr }}</div>
<h2>Apartament {{ nr }}</h2>
</div>
<!-- Dostęp -->
<section class="card">
<h3 class="card-title">Dane dostępu</h3>
<div class="field">
<label>
<span class="label-dot" style="color:var(--gold)">&#9679;</span>
Kod PIN
</label>
<div class="input-row">
<input
v-model="pin"
type="password"
placeholder="Nowy kod PIN"
maxlength="8"
autocomplete="new-password"
:disabled="pinLoading"
@keydown.enter="submitPin"
/>
<button
class="btn-primary action-btn"
:disabled="!pin || pinLoading"
@click="submitPin"
>
<span v-if="pinLoading" class="spinner" />
<span v-else>Ustaw</span>
</button>
</div>
<transition name="fade">
<div v-if="pinFeedback" :class="['feedback', pinFeedback.type]">
{{ pinFeedback.msg }}
</div>
</transition>
<div class="last-changed">Ostatnia zmiana: {{ formatDate(pinLastChanged) }}</div>
</div>
<div class="divider" />
<div class="field">
<label>
<span class="label-dot" style="color:var(--gold)">&#9632;</span>
Karta zbliżeniowa
</label>
<div class="input-row">
<input
v-model="card"
type="text"
placeholder="Numer karty (np. 2438620732)"
maxlength="20"
autocomplete="off"
:disabled="cardLoading"
@keydown.enter="submitCard"
/>
<button
class="btn-primary action-btn"
:disabled="!card || cardLoading"
@click="submitCard"
>
<span v-if="cardLoading" class="spinner" />
<span v-else>Ustaw</span>
</button>
</div>
<transition name="fade">
<div v-if="cardFeedback" :class="['feedback', cardFeedback.type]">
{{ cardFeedback.msg }}
</div>
</transition>
<div class="last-changed">Ostatnia zmiana: {{ formatDate(cardLastChanged) }}</div>
</div>
</section>
<!-- Daty meldunku -->
<section class="card">
<div class="card-title-row">
<h3 class="card-title">Meldunek</h3>
<label class="checkbox-label">
<input type="checkbox" v-model="updateDates" />
<span class="checkbox-custom" />
<span>Aktualizuj czas meldunku</span>
</label>
</div>
<div :class="['dates-grid', { 'dates-disabled': !updateDates }]">
<div class="field">
<label>Zameldowanie</label>
<input type="datetime-local" v-model="checkin" :disabled="!updateDates" />
</div>
<div class="field">
<label>Wymeldowanie</label>
<input type="datetime-local" v-model="checkout" :disabled="!updateDates" />
</div>
</div>
<transition name="fade">
<div v-if="datesFeedback" :class="['feedback', datesFeedback.type]">
{{ datesFeedback.msg }}
</div>
</transition>
<div class="card-actions">
<button
class="btn-primary"
:disabled="datesLoading || !updateDates"
@click="submitDates"
>
<span v-if="datesLoading" class="spinner" />
<span v-else>Zapisz daty meldunku</span>
</button>
</div>
</section>
<!-- Główne przyciski -->
<transition name="fade">
<div v-if="allFeedback" :class="['feedback', 'feedback-main', allFeedback.type]">
{{ allFeedback.msg }}
</div>
</transition>
</main>
<div class="bottom-bar">
<div class="bottom-bar-inner">
<button class="btn-ghost btn-lg" @click="$emit('back')">
&#8592; Cofnij
</button>
<button
class="btn-primary btn-lg"
:disabled="saveAllLoading"
@click="openModeDialog"
>
<span v-if="saveAllLoading" class="spinner spinner-dark" />
<span v-else>Zapisz wszystko</span>
</button>
</div>
</div>
<!-- Modal: tryb zapisu -->
<transition name="modal">
<div v-if="showModeDialog" class="modal-backdrop" @click.self="showModeDialog = false">
<div class="modal-box">
<p class="modal-question">Rodzaj operacji dla apartamentu {{ nr }}?</p>
<div class="modal-choices">
<button class="modal-choice" @click="confirmMode('new')">
<span class="choice-icon">&#9675;</span>
<span class="choice-label">Nowy gość</span>
<span class="choice-desc">Zapisz dane i aktywuj automatyzację świateł przy pierwszym wejściu</span>
</button>
<button class="modal-choice" @click="confirmMode('reset')">
<span class="choice-icon">&#9632;</span>
<span class="choice-label">Reset PIN / karty</span>
<span class="choice-desc">Tylko aktualizacja danych dostępu, bez zmiany automatyzacji</span>
</button>
</div>
<button class="modal-cancel" @click="showModeDialog = false">Anuluj</button>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { setPin, setCard, setCheckin, setCheckout, verifyAll, getLastChanged, setNewGuest } from '../services/ha.js'
const props = defineProps({
nr: { type: Number, required: true },
token: { type: String, required: true },
})
const emit = defineEmits(['back', 'logout'])
// ── Helpers ────────────────────────────────────────────────
function pad(n) { return String(n).padStart(2, '0') }
function toDatetimeLocal(date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function defaultCheckin() {
return toDatetimeLocal(new Date())
}
function defaultCheckout() {
const d = new Date()
d.setDate(d.getDate() + 1)
d.setHours(11, 0, 0, 0)
return toDatetimeLocal(d)
}
const FMT = new Intl.DateTimeFormat('pl-PL', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
function formatDate(date) {
if (!date) return '—'
return FMT.format(date)
}
function showFeedback(target, type, msg, duration = 3500) {
target.value = { type, msg }
setTimeout(() => { target.value = null }, duration)
}
// ── State ──────────────────────────────────────────────────
const checkin = ref(defaultCheckin())
const checkout = ref(defaultCheckout())
const datesLoading = ref(false)
const datesFeedback = ref(null)
const pin = ref('')
const pinLoading = ref(false)
const pinFeedback = ref(null)
const card = ref('')
const cardLoading = ref(false)
const cardFeedback = ref(null)
const updateDates = ref(true)
const saveAllLoading = ref(false)
const allFeedback = ref(null)
const showModeDialog = ref(false)
const pinLastChanged = ref(null)
const cardLastChanged = ref(null)
onMounted(async () => {
const [pinDate, cardDate] = await Promise.all([
getLastChanged(props.token, `input_number.apartament_${props.nr}`),
getLastChanged(props.token, `input_text.apartament_karta_${props.nr}`),
])
pinLastChanged.value = pinDate
cardLastChanged.value = cardDate
})
// ── Dialog ─────────────────────────────────────────────────
function openModeDialog() {
showModeDialog.value = true
}
async function confirmMode(mode) {
showModeDialog.value = false
await submitAll(mode)
}
// ── Actions ────────────────────────────────────────────────
async function submitAll(mode) {
saveAllLoading.value = true
allFeedback.value = null
const pinToSave = pin.value || null
const cardToSave = card.value || null
try {
// 1. Zapis do HA
const writes = []
if (updateDates.value) {
writes.push(setCheckin(props.token, props.nr, checkin.value))
writes.push(setCheckout(props.token, props.nr, checkout.value))
}
if (pinToSave) writes.push(setPin(props.token, props.nr, pinToSave))
if (cardToSave) writes.push(setCard(props.token, props.nr, cardToSave))
if (mode === 'new') writes.push(setNewGuest(props.token, props.nr, true))
await Promise.all(writes)
// 2. Krótka chwila — HA potrzebuje ~200ms na aktualizację stanu
await new Promise(r => setTimeout(r, 300))
// 3. Weryfikacja — odczyt z HA i porównanie
const errors = await verifyAll(props.token, props.nr, {
checkin: updateDates.value ? checkin.value : null,
checkout: updateDates.value ? checkout.value : null,
pin: pinToSave,
card: cardToSave,
})
if (errors.length === 0) {
const now = new Date()
if (pinToSave) pinLastChanged.value = now
if (cardToSave) cardLastChanged.value = now
pin.value = ''
card.value = ''
const modeMsg = mode === 'new' ? ' Flaga nowego gościa aktywowana.' : ''
showFeedback(allFeedback, 'success', `Zapisano i zweryfikowano — wszystkie dane są poprawne.${modeMsg}`)
} else {
showFeedback(
allFeedback,
'error',
'Zapis częściowo nieudany:\n' + errors.join('\n'),
6000
)
}
} catch (err) {
showFeedback(allFeedback, 'error', `Błąd: ${err.message}`)
} finally {
saveAllLoading.value = false
}
}
async function submitDates() {
datesLoading.value = true
try {
await Promise.all([
setCheckin(props.token, props.nr, checkin.value),
setCheckout(props.token, props.nr, checkout.value),
])
showFeedback(datesFeedback, 'success', 'Daty meldunku zostały zapisane.')
} catch (err) {
showFeedback(datesFeedback, 'error', `Błąd: ${err.message}`)
} finally {
datesLoading.value = false
}
}
async function submitPin() {
if (!pin.value || pinLoading.value) return
pinLoading.value = true
try {
await setPin(props.token, props.nr, pin.value)
pin.value = ''
pinLastChanged.value = new Date()
showFeedback(pinFeedback, 'success', 'Kod PIN został zaktualizowany.')
} catch (err) {
showFeedback(pinFeedback, 'error', `Błąd: ${err.message}`)
} finally {
pinLoading.value = false
}
}
async function submitCard() {
if (!card.value || cardLoading.value) return
cardLoading.value = true
try {
await setCard(props.token, props.nr, card.value)
card.value = ''
cardLastChanged.value = new Date()
showFeedback(cardFeedback, 'success', 'Numer karty został zaktualizowany.')
} catch (err) {
showFeedback(cardFeedback, 'error', `Błąd: ${err.message}`)
} finally {
cardLoading.value = false
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
background:
radial-gradient(ellipse at 20% 80%, rgba(185, 150, 60, 0.03) 0%, transparent 55%),
var(--bg);
}
/* ── Topbar ───────────────────────────────────────────────── */
.topbar {
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: rgba(12, 12, 12, 0.92);
backdrop-filter: blur(12px);
z-index: 100;
}
.topbar-inner {
max-width: 680px;
margin: 0 auto;
padding: 0 32px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark { color: var(--gold); font-size: 9px; }
.brand-name { font-family: var(--font-serif); font-size: 17px; letter-spacing: 0.03em; }
.logout-btn { font-size: 12px; padding: 7px 16px; }
/* ── Content ─────────────────────────────────────────────── */
.content {
max-width: 680px;
margin: 0 auto;
width: 100%;
padding: 36px 32px 60px;
}
.back-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 13px;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 32px;
text-transform: none;
letter-spacing: normal;
transition: color var(--transition);
}
.back-btn:hover { color: var(--text); }
.back-arrow { font-size: 16px; }
.apt-heading {
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 32px;
}
.apt-badge {
width: 52px;
height: 52px;
border: 1px solid var(--gold);
color: var(--gold);
font-family: var(--font-serif);
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
flex-shrink: 0;
}
.apt-heading h2 {
font-size: 26px;
color: var(--text);
}
/* ── Checkbox ────────────────────────────────────────────── */
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
}
.card-title-row .card-title {
margin-bottom: 0;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 12px;
color: var(--text-muted);
text-transform: none;
letter-spacing: normal;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-custom {
width: 16px;
height: 16px;
border: 1px solid var(--border);
border-radius: 3px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color var(--transition), background var(--transition);
}
.checkbox-label input:checked + .checkbox-custom {
background: var(--gold);
border-color: var(--gold);
}
.checkbox-label input:checked + .checkbox-custom::after {
content: '';
display: block;
width: 4px;
height: 7px;
border: 2px solid #000;
border-top: none;
border-left: none;
transform: rotate(45deg) translate(-1px, -1px);
}
.checkbox-label:hover .checkbox-custom {
border-color: var(--gold);
}
.dates-disabled {
opacity: 0.35;
pointer-events: none;
}
/* ── Cards ───────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 28px;
margin-bottom: 16px;
}
.card-title {
font-size: 13px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 22px;
font-family: var(--font-sans);
font-weight: 400;
}
/* ── Dates ───────────────────────────────────────────────── */
.dates-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 8px;
}
.field { margin-bottom: 4px; }
label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
}
.label-dot { font-size: 7px; }
/* Style datetime-local jak zwykły input */
input[type="datetime-local"] {
color-scheme: dark;
}
.card-actions {
margin-top: 20px;
}
.card-actions .btn-primary {
padding: 11px 24px;
}
/* ── Access fields ───────────────────────────────────────── */
.input-row {
display: flex;
gap: 8px;
}
.input-row input { flex: 1; }
.action-btn {
padding: 10px 16px;
white-space: nowrap;
flex-shrink: 0;
}
/* ── Spinner ─────────────────────────────────────────────── */
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid rgba(0,0,0,0.3);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.last-changed {
margin-top: 7px;
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.03em;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s ease, transform 0.25s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(-4px); }
/* ── Bottom bar ──────────────────────────────────────────── */
.bottom-bar {
position: sticky;
bottom: 0;
border-top: 1px solid var(--border);
background: rgba(12, 12, 12, 0.95);
backdrop-filter: blur(12px);
z-index: 100;
padding: 16px 32px;
}
.bottom-bar-inner {
max-width: 680px;
margin: 0 auto;
display: flex;
gap: 12px;
}
.btn-lg {
flex: 1;
padding: 15px 24px;
font-size: 14px;
border-radius: var(--radius-lg);
}
.spinner-dark {
border-color: rgba(0,0,0,0.25);
border-top-color: #000;
}
.feedback-main {
margin: 0 0 16px;
}
/* ── Modal ───────────────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 200;
backdrop-filter: blur(4px);
}
.modal-box {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 32px 28px 24px;
width: 100%;
max-width: 460px;
}
.modal-question {
font-family: var(--font-serif);
font-size: 19px;
color: var(--text);
margin-bottom: 24px;
text-align: center;
}
.modal-choices {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.modal-choice {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
cursor: pointer;
text-align: left;
display: grid;
grid-template-columns: 20px 1fr;
grid-template-rows: auto auto;
column-gap: 12px;
row-gap: 4px;
transition: background var(--transition), border-color var(--transition);
}
.modal-choice:hover {
background: rgba(185, 150, 60, 0.08);
border-color: rgba(185, 150, 60, 0.4);
}
.choice-icon {
grid-row: 1 / 3;
align-self: center;
color: var(--gold);
font-size: 9px;
}
.choice-label {
font-family: var(--font-sans);
font-size: 14px;
color: var(--text);
letter-spacing: 0.02em;
}
.choice-desc {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.modal-cancel {
width: 100%;
background: none;
border: none;
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 13px;
padding: 10px;
cursor: pointer;
transition: color var(--transition);
}
.modal-cancel:hover { color: var(--text); }
/* Modal transition */
.modal-enter-active, .modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active .modal-box, .modal-leave-active .modal-box {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modal-enter-from, .modal-leave-to { opacity: 0; }
.modal-enter-from .modal-box, .modal-leave-to .modal-box {
transform: translateY(12px);
opacity: 0;
}
@media (max-width: 600px) {
.topbar-inner, .content { padding-left: 16px; padding-right: 16px; }
.dates-grid { grid-template-columns: 1fr; }
.bottom-bar { padding: 12px 16px; }
}
</style>

View File

@@ -0,0 +1,730 @@
<template>
<div class="page">
<header class="topbar">
<div class="topbar-inner">
<div class="brand">
<span class="brand-mark">&#9632;</span>
<span class="brand-name">Panel Dostępu</span>
</div>
<button class="btn-ghost logout-btn" @click="$emit('logout')">Wyloguj</button>
</div>
</header>
<main class="content">
<button class="back-btn" @click="$emit('back')">
<span class="back-arrow">&#8592;</span> Powrót do listy
</button>
<div class="apt-heading">
<div class="apt-badge">{{ nr }}</div>
<h2>Apartament {{ nr }}</h2>
</div>
<!-- Dostęp -->
<section class="card">
<div class="field">
<label>
<span class="label-dot" style="color:var(--gold)">&#9632;</span>
Karta zbliżeniowa
</label>
<div class="input-row">
<input
v-model="card"
type="password"
placeholder="Numer karty (np. 2438620732)"
maxlength="20"
autocomplete="off"
:disabled="cardLoading"
@keydown.enter="submitCard"
/>
<button
class="btn-primary action-btn"
:disabled="!card || cardLoading"
@click="submitCard"
>
<span v-if="cardLoading" class="spinner" />
<span v-else>Ustaw</span>
</button>
</div>
<transition name="fade">
<div v-if="cardFeedback" :class="['feedback', cardFeedback.type]">
{{ cardFeedback.msg }}
</div>
</transition>
<div class="last-changed">Ostatnia zmiana: {{ formatDate(cardLastChanged) }}</div>
</div>
</section>
<!-- Daty meldunku -->
<section class="card">
<div class="card-title-row">
<h3 class="card-title">Meldunek</h3>
<label class="checkbox-label">
<input type="checkbox" v-model="updateDates" />
<span class="checkbox-custom" />
<span>Aktualizuj czas meldunku</span>
</label>
</div>
<div :class="['dates-grid', { 'dates-disabled': !updateDates }]">
<div class="field">
<label>Zameldowanie</label>
<input type="datetime-local" v-model="checkin" :disabled="!updateDates" />
</div>
<div class="field">
<label>Wymeldowanie</label>
<input type="datetime-local" v-model="checkout" :disabled="!updateDates" />
</div>
</div>
<transition name="fade">
<div v-if="datesFeedback" :class="['feedback', datesFeedback.type]">
{{ datesFeedback.msg }}
</div>
</transition>
<div class="card-actions">
<button
class="btn-primary"
:disabled="datesLoading || !updateDates"
@click="submitDates"
>
<span v-if="datesLoading" class="spinner" />
<span v-else>Zapisz daty meldunku</span>
</button>
</div>
</section>
<!-- Główne przyciski -->
<transition name="fade">
<div v-if="allFeedback" :class="['feedback', 'feedback-main', allFeedback.type]">
{{ allFeedback.msg }}
</div>
</transition>
</main>
<div class="bottom-bar">
<div class="bottom-bar-inner">
<button class="btn-ghost btn-lg" @click="$emit('back')">
&#8592; Cofnij
</button>
<button
class="btn-primary btn-lg"
:disabled="saveAllLoading"
@click="openModeDialog"
>
<span v-if="saveAllLoading" class="spinner spinner-dark" />
<span v-else>Zapisz wszystko</span>
</button>
</div>
</div>
<!-- Modal: tryb zapisu -->
<transition name="modal">
<div v-if="showModeDialog" class="modal-backdrop" @click.self="showModeDialog = false">
<div class="modal-box">
<p class="modal-question">Rodzaj operacji dla apartamentu {{ nr }}?</p>
<div class="modal-choices">
<button class="modal-choice" @click="confirmMode('new')">
<span class="choice-icon">&#9675;</span>
<span class="choice-label">Nowy gość</span>
<span class="choice-desc">Zapisz dane i aktywuj automatyzację świateł przy pierwszym wejściu</span>
</button>
<button class="modal-choice" @click="confirmMode('reset')">
<span class="choice-icon">&#9632;</span>
<span class="choice-label">Reset PIN / karty</span>
<span class="choice-desc">Tylko aktualizacja danych dostępu, bez zmiany automatyzacji</span>
</button>
</div>
<button class="modal-cancel" @click="showModeDialog = false">Anuluj</button>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { setPin, setCard, setCheckin, setCheckout, verifyAll, getLastChanged, setNewGuest } from '../services/ha.js'
const props = defineProps({
nr: { type: Number, required: true },
token: { type: String, required: true },
})
const emit = defineEmits(['back', 'logout'])
// ── Helpers ────────────────────────────────────────────────
function pad(n) { return String(n).padStart(2, '0') }
function toDatetimeLocal(date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function defaultCheckin() {
return toDatetimeLocal(new Date())
}
function defaultCheckout() {
const d = new Date()
d.setDate(d.getDate() + 1)
d.setHours(11, 0, 0, 0)
return toDatetimeLocal(d)
}
const FMT = new Intl.DateTimeFormat('pl-PL', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
function formatDate(date) {
if (!date) return '—'
return FMT.format(date)
}
function showFeedback(target, type, msg, duration = 3500) {
target.value = { type, msg }
setTimeout(() => { target.value = null }, duration)
}
// ── State ──────────────────────────────────────────────────
const checkin = ref(defaultCheckin())
const checkout = ref(defaultCheckout())
const datesLoading = ref(false)
const datesFeedback = ref(null)
const pin = ref('')
const pinLoading = ref(false)
const pinFeedback = ref(null)
const card = ref('')
const cardLoading = ref(false)
const cardFeedback = ref(null)
const updateDates = ref(true)
const saveAllLoading = ref(false)
const allFeedback = ref(null)
const showModeDialog = ref(false)
const pinLastChanged = ref(null)
const cardLastChanged = ref(null)
onMounted(async () => {
const [pinDate, cardDate] = await Promise.all([
getLastChanged(props.token, `input_number.apartament_${props.nr}`),
getLastChanged(props.token, `input_text.apartament_karta_${props.nr}`),
])
pinLastChanged.value = pinDate
cardLastChanged.value = cardDate
})
// ── Dialog ─────────────────────────────────────────────────
function openModeDialog() {
showModeDialog.value = true
}
async function confirmMode(mode) {
showModeDialog.value = false
await submitAll(mode)
}
// ── Actions ────────────────────────────────────────────────
async function submitAll(mode) {
saveAllLoading.value = true
allFeedback.value = null
const pinToSave = pin.value || null
const cardToSave = card.value || null
try {
// 1. Zapis do HA
const writes = []
if (updateDates.value) {
writes.push(setCheckin(props.token, props.nr, checkin.value))
writes.push(setCheckout(props.token, props.nr, checkout.value))
}
if (pinToSave) writes.push(setPin(props.token, props.nr, pinToSave))
if (cardToSave) writes.push(setCard(props.token, props.nr, cardToSave))
if (mode === 'new') writes.push(setNewGuest(props.token, props.nr, true))
await Promise.all(writes)
// 2. Krótka chwila — HA potrzebuje ~200ms na aktualizację stanu
await new Promise(r => setTimeout(r, 300))
// 3. Weryfikacja — odczyt z HA i porównanie
const errors = await verifyAll(props.token, props.nr, {
checkin: updateDates.value ? checkin.value : null,
checkout: updateDates.value ? checkout.value : null,
pin: pinToSave,
card: cardToSave,
})
if (errors.length === 0) {
const now = new Date()
if (pinToSave) pinLastChanged.value = now
if (cardToSave) cardLastChanged.value = now
pin.value = ''
card.value = ''
const modeMsg = mode === 'new' ? ' Flaga nowego gościa aktywowana.' : ''
showFeedback(allFeedback, 'success', `Zapisano i zweryfikowano — wszystkie dane są poprawne.${modeMsg}`)
} else {
showFeedback(
allFeedback,
'error',
'Zapis częściowo nieudany:\n' + errors.join('\n'),
6000
)
}
} catch (err) {
showFeedback(allFeedback, 'error', `Błąd: ${err.message}`)
} finally {
saveAllLoading.value = false
}
}
async function submitDates() {
datesLoading.value = true
try {
await Promise.all([
setCheckin(props.token, props.nr, checkin.value),
setCheckout(props.token, props.nr, checkout.value),
])
showFeedback(datesFeedback, 'success', 'Daty meldunku zostały zapisane.')
} catch (err) {
showFeedback(datesFeedback, 'error', `Błąd: ${err.message}`)
} finally {
datesLoading.value = false
}
}
async function submitPin() {
if (!pin.value || pinLoading.value) return
pinLoading.value = true
try {
await setPin(props.token, props.nr, pin.value)
pin.value = ''
pinLastChanged.value = new Date()
showFeedback(pinFeedback, 'success', 'Kod PIN został zaktualizowany.')
} catch (err) {
showFeedback(pinFeedback, 'error', `Błąd: ${err.message}`)
} finally {
pinLoading.value = false
}
}
async function submitCard() {
if (!card.value || cardLoading.value) return
cardLoading.value = true
try {
await setCard(props.token, props.nr, card.value)
card.value = ''
cardLastChanged.value = new Date()
showFeedback(cardFeedback, 'success', 'Numer karty został zaktualizowany.')
} catch (err) {
showFeedback(cardFeedback, 'error', `Błąd: ${err.message}`)
} finally {
cardLoading.value = false
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
background:
radial-gradient(ellipse at 20% 80%, rgba(185, 150, 60, 0.03) 0%, transparent 55%),
var(--bg);
}
/* ── Topbar ───────────────────────────────────────────────── */
.topbar {
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: rgba(12, 12, 12, 0.92);
backdrop-filter: blur(12px);
z-index: 100;
}
.topbar-inner {
max-width: 680px;
margin: 0 auto;
padding: 0 32px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark { color: var(--gold); font-size: 9px; }
.brand-name { font-family: var(--font-serif); font-size: 17px; letter-spacing: 0.03em; }
.logout-btn { font-size: 12px; padding: 7px 16px; }
/* ── Content ─────────────────────────────────────────────── */
.content {
max-width: 680px;
margin: 0 auto;
width: 100%;
padding: 36px 32px 60px;
}
.back-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 13px;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 32px;
text-transform: none;
letter-spacing: normal;
transition: color var(--transition);
}
.back-btn:hover { color: var(--text); }
.back-arrow { font-size: 16px; }
.apt-heading {
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 32px;
}
.apt-badge {
width: 52px;
height: 52px;
border: 1px solid var(--gold);
color: var(--gold);
font-family: var(--font-serif);
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
flex-shrink: 0;
}
.apt-heading h2 {
font-size: 26px;
color: var(--text);
}
/* ── Checkbox ────────────────────────────────────────────── */
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
}
.card-title-row .card-title {
margin-bottom: 0;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 12px;
color: var(--text-muted);
text-transform: none;
letter-spacing: normal;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-custom {
width: 16px;
height: 16px;
border: 1px solid var(--border);
border-radius: 3px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color var(--transition), background var(--transition);
}
.checkbox-label input:checked + .checkbox-custom {
background: var(--gold);
border-color: var(--gold);
}
.checkbox-label input:checked + .checkbox-custom::after {
content: '';
display: block;
width: 4px;
height: 7px;
border: 2px solid #000;
border-top: none;
border-left: none;
transform: rotate(45deg) translate(-1px, -1px);
}
.checkbox-label:hover .checkbox-custom {
border-color: var(--gold);
}
.dates-disabled {
opacity: 0.35;
pointer-events: none;
}
/* ── Cards ───────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 28px;
margin-bottom: 16px;
}
.card-title {
font-size: 13px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 22px;
font-family: var(--font-sans);
font-weight: 400;
}
/* ── Dates ───────────────────────────────────────────────── */
.dates-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 8px;
}
.field { margin-bottom: 4px; }
label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
}
.label-dot { font-size: 7px; }
/* Style datetime-local jak zwykły input */
input[type="datetime-local"] {
color-scheme: dark;
}
.card-actions {
margin-top: 20px;
}
.card-actions .btn-primary {
padding: 11px 24px;
}
/* ── Access fields ───────────────────────────────────────── */
.input-row {
display: flex;
gap: 8px;
}
.input-row input { flex: 1; }
.action-btn {
padding: 10px 16px;
white-space: nowrap;
flex-shrink: 0;
}
/* ── Spinner ─────────────────────────────────────────────── */
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid rgba(0,0,0,0.3);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.last-changed {
margin-top: 7px;
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.03em;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s ease, transform 0.25s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(-4px); }
/* ── Bottom bar ──────────────────────────────────────────── */
.bottom-bar {
position: sticky;
bottom: 0;
border-top: 1px solid var(--border);
background: rgba(12, 12, 12, 0.95);
backdrop-filter: blur(12px);
z-index: 100;
padding: 16px 32px;
}
.bottom-bar-inner {
max-width: 680px;
margin: 0 auto;
display: flex;
gap: 12px;
}
.btn-lg {
flex: 1;
padding: 15px 24px;
font-size: 14px;
border-radius: var(--radius-lg);
}
.spinner-dark {
border-color: rgba(0,0,0,0.25);
border-top-color: #000;
}
.feedback-main {
margin: 0 0 16px;
}
/* ── Modal ───────────────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 200;
backdrop-filter: blur(4px);
}
.modal-box {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 32px 28px 24px;
width: 100%;
max-width: 460px;
}
.modal-question {
font-family: var(--font-serif);
font-size: 19px;
color: var(--text);
margin-bottom: 24px;
text-align: center;
}
.modal-choices {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.modal-choice {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
cursor: pointer;
text-align: left;
display: grid;
grid-template-columns: 20px 1fr;
grid-template-rows: auto auto;
column-gap: 12px;
row-gap: 4px;
transition: background var(--transition), border-color var(--transition);
}
.modal-choice:hover {
background: rgba(185, 150, 60, 0.08);
border-color: rgba(185, 150, 60, 0.4);
}
.choice-icon {
grid-row: 1 / 3;
align-self: center;
color: var(--gold);
font-size: 9px;
}
.choice-label {
font-family: var(--font-sans);
font-size: 14px;
color: var(--text);
letter-spacing: 0.02em;
}
.choice-desc {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.modal-cancel {
width: 100%;
background: none;
border: none;
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 13px;
padding: 10px;
cursor: pointer;
transition: color var(--transition);
}
.modal-cancel:hover { color: var(--text); }
/* Modal transition */
.modal-enter-active, .modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active .modal-box, .modal-leave-active .modal-box {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modal-enter-from, .modal-leave-to { opacity: 0; }
.modal-enter-from .modal-box, .modal-leave-to .modal-box {
transform: translateY(12px);
opacity: 0;
}
@media (max-width: 600px) {
.topbar-inner, .content { padding-left: 16px; padding-right: 16px; }
.dates-grid { grid-template-columns: 1fr; }
.bottom-bar { padding: 12px 16px; }
}
</style>

View File

@@ -0,0 +1,306 @@
<template>
<div class="dashboard">
<header class="topbar">
<div class="topbar-inner">
<div class="brand">
<span class="brand-mark">&#9632;</span>
<span class="brand-name">Panel Dostępu</span>
</div>
<button class="btn-ghost logout-btn" @click="$emit('logout')">Wyloguj</button>
</div>
</header>
<main class="content">
<div class="page-title">
<h2>Wybierz apartament</h2>
<p class="page-sub">Kliknij apartament aby zarządzać dostępem i datami meldunku</p>
</div>
<div class="apt-grid">
<button
v-for="nr in apartments"
:key="nr"
class="apt-tile"
:disabled="resetting"
@click="$emit('select', nr)"
>
<span class="apt-num">{{ nr }}</span>
<span class="apt-label">Apartament</span>
</button>
</div>
<div class="reset-section">
<div class="divider" />
<!-- Potwierdzenie -->
<div v-if="confirming" class="confirm-box">
<p class="confirm-text">
Wszystkie apartamenty (1{{ apartments.length }}) zostaną zresetowane:<br />
<strong>PIN 2137</strong> &nbsp;|&nbsp; <strong>Karta 0</strong>
</p>
<div class="confirm-actions">
<button class="btn-ghost btn-sm" :disabled="resetting" @click="confirming = false">
Anuluj
</button>
<button class="btn-danger btn-sm" :disabled="resetting" @click="doReset">
<span v-if="resetting" class="spinner spinner-light" />
<span v-else>Tak, resetuj wszystko</span>
</button>
</div>
</div>
<div v-else class="reset-row">
<transition name="fade">
<div v-if="resetFeedback" :class="['feedback', resetFeedback.type]">
{{ resetFeedback.msg }}
</div>
</transition>
<button class="btn-reset" @click="confirming = true">
Resetuj wszystkie apartamenty
</button>
</div>
</div>
</main>
<footer class="footer">
<span>Panel Dostępu &mdash; tylko do użytku wewnętrznego</span>
</footer>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { setPin, setCard } from '../services/ha.js'
const props = defineProps({
token: { type: String, required: true },
})
defineEmits(['logout', 'select'])
const apartments = Array.from(
{ length: Number(import.meta.env.VITE_APARTMENTS) || 7 },
(_, i) => i + 1
)
const confirming = ref(false)
const resetting = ref(false)
const resetFeedback = ref(null)
async function doReset() {
resetting.value = true
resetFeedback.value = null
try {
await Promise.all(
apartments.flatMap(nr => [
setPin(props.token, nr, 2137),
setCard(props.token, nr, '0'),
])
)
resetFeedback.value = { type: 'success', msg: `Zresetowano ${apartments.length} apartamentów — PIN: 2137, Karta: 0` }
setTimeout(() => { resetFeedback.value = null }, 5000)
} catch (err) {
resetFeedback.value = { type: 'error', msg: `Błąd resetu: ${err.message}` }
} finally {
resetting.value = false
confirming.value = false
}
}
</script>
<style scoped>
.dashboard {
min-height: 100vh;
display: flex;
flex-direction: column;
background:
radial-gradient(ellipse at 80% 10%, rgba(185, 150, 60, 0.03) 0%, transparent 55%),
var(--bg);
}
.topbar {
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: rgba(12, 12, 12, 0.92);
backdrop-filter: blur(12px);
z-index: 100;
}
.topbar-inner {
max-width: 900px;
margin: 0 auto;
padding: 0 32px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark { color: var(--gold); font-size: 9px; }
.brand-name { font-family: var(--font-serif); font-size: 17px; letter-spacing: 0.03em; }
.logout-btn { font-size: 12px; padding: 7px 16px; }
.content {
flex: 1;
max-width: 900px;
margin: 0 auto;
width: 100%;
padding: 48px 32px;
}
.page-title { margin-bottom: 40px; }
.page-title h2 { font-size: 30px; margin-bottom: 8px; }
.page-sub { color: var(--text-muted); font-size: 13px; }
/* ── Apartment tiles ─────────────────────────────────────── */
.apt-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
}
.apt-tile {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 32px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
transition: border-color var(--transition), background var(--transition), transform var(--transition);
font-family: inherit;
text-transform: none;
letter-spacing: normal;
}
.apt-tile:hover:not(:disabled) {
border-color: var(--gold);
background: #1e1c17;
transform: translateY(-2px);
}
.apt-tile:active:not(:disabled) { transform: translateY(0); }
.apt-tile:disabled { opacity: 0.4; cursor: not-allowed; }
.apt-num {
font-family: var(--font-serif);
font-size: 42px;
color: var(--gold);
line-height: 1;
}
.apt-label {
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
/* ── Reset section ───────────────────────────────────────── */
.reset-section { margin-top: 40px; }
.reset-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.btn-reset {
background: transparent;
border: 1px solid rgba(139, 58, 58, 0.4);
color: #c97a7a;
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: 0.07em;
text-transform: uppercase;
padding: 9px 18px;
border-radius: var(--radius);
cursor: pointer;
transition: border-color var(--transition), color var(--transition), background var(--transition);
}
.btn-reset:hover {
border-color: rgba(139, 58, 58, 0.8);
color: #e08080;
background: rgba(139, 58, 58, 0.07);
}
/* Confirm box */
.confirm-box {
background: rgba(139, 58, 58, 0.08);
border: 1px solid rgba(139, 58, 58, 0.3);
border-radius: var(--radius-lg);
padding: 20px 24px;
max-width: 480px;
}
.confirm-text {
font-size: 14px;
color: var(--text);
margin-bottom: 16px;
line-height: 1.7;
}
.confirm-text strong { color: #e08080; }
.confirm-actions {
display: flex;
gap: 10px;
}
.btn-sm { padding: 8px 18px; font-size: 12px; }
.btn-danger {
background: #7a2a2a;
color: #f0d0d0;
border: none;
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: 0.07em;
text-transform: uppercase;
padding: 8px 18px;
border-radius: var(--radius);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background var(--transition);
}
.btn-danger:hover:not(:disabled) { background: #8f3232; }
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.footer {
border-top: 1px solid var(--border);
padding: 16px 32px;
text-align: center;
color: var(--text-muted);
font-size: 12px;
letter-spacing: 0.04em;
}
@media (max-width: 600px) {
.topbar-inner, .content { padding-left: 16px; padding-right: 16px; }
.apt-grid { grid-template-columns: repeat(3, 1fr); }
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<div class="login-wrap">
<div class="login-box">
<div class="logo">
<div class="logo-mark">&#9632;</div>
<h1>Panel Dostępu</h1>
<p class="subtitle">System zarządzania dostępem</p>
</div>
<div class="divider" />
<form @submit.prevent="handleLogin">
<div class="field">
<label for="username">Login</label>
<input
id="username"
v-model="username"
type="text"
placeholder="Login"
autocomplete="username"
:disabled="loading"
/>
</div>
<div class="field">
<label for="password">Hasło</label>
<input
id="password"
v-model="password"
type="password"
placeholder="Hasło"
autocomplete="current-password"
:disabled="loading"
/>
</div>
<div v-if="error" class="feedback error">{{ error }}</div>
<button
type="submit"
class="btn-primary login-btn"
:disabled="!username || !password || loading"
>
<span v-if="loading" class="spinner spinner-dark" />
<span v-else>Zaloguj się</span>
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { panelLogin } from '../services/auth.js'
const emit = defineEmits(['authenticated'])
const username = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
async function handleLogin() {
loading.value = true
error.value = ''
// Krótkie opóźnienie żeby przycisk nie migał przy szybkim kliknięciu
await new Promise(r => setTimeout(r, 300))
if (panelLogin(username.value, password.value)) {
emit('authenticated')
} else {
error.value = 'Nieprawidłowy login lub hasło.'
}
loading.value = false
}
</script>
<style scoped>
.login-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(ellipse at 20% 50%, rgba(185, 150, 60, 0.04) 0%, transparent 60%),
var(--bg);
}
.login-box {
width: 100%;
max-width: 400px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 40px 36px;
}
.logo { text-align: center; margin-bottom: 28px; }
.logo-mark { color: var(--gold); font-size: 10px; letter-spacing: 6px; margin-bottom: 14px; opacity: 0.8; }
.logo h1 { font-size: 26px; color: var(--text); margin-bottom: 6px; }
.subtitle { color: var(--text-muted); font-size: 13px; letter-spacing: 0.04em; }
.field { margin-bottom: 14px; }
label {
display: block;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
}
.login-btn {
width: 100%;
padding: 13px;
font-size: 13px;
margin-top: 6px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: var(--radius-lg);
}
.spinner-dark {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(0,0,0,0.25);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View File

@@ -0,0 +1,28 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const haUrl = env.VITE_HA_URL || 'http://10.20.10.106:8123'
return {
plugins: [vue()],
server: {
port: 5173,
host: true,
proxy: {
'/api': {
target: haUrl,
changeOrigin: true,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
// Upewnij się że Authorization header jest przekazywany
const auth = req.headers['authorization']
if (auth) proxyReq.setHeader('Authorization', auth)
})
},
},
},
},
}
})