Aqara C1 Pet Feeder Card

Hi, everybody.
I’ve acquired an Aqara C1 Pet Feeder and connected it via Zigbee2MQTT, but noticed that there was a limit in the amount of automatic feeding schedules to a maximum of 4 entries. I learned this is because of the inherent 255-character limit of the entity that governs the schedule.

So, I’ve decided to create my own script, with an automation and a lovelace card that would automate a feeding schedule for my cat with up to 20 entries (I thought 20 was a decent number).

Maybe, someone out there could find it useful for their own dashboard. I’ve been using it for quite some time and it seems to work fine. But do let me know if you have any trouble.

Oh, by the way. Script UI elements are in Spanish but you can easily change these to your liking. All the code, instructions, etc. are in English so it should be fairly simple to translate.

Prerequisites

  • Home Assistant OS (haven’t tested it elsewhere)
  • Aqara Pet Feeder connected via zigbee2mqtt
  • Mosquitto broker (already configured)
  • HACS installed (optional, but recommended)

Installation Steps

1. Add Input_Text helpers

These helpers will save the schedule information. Adding them this way makes sure of data persistence.

Add three text input helpers in Settings > Devices & Services > Helpers > Add new Helper
Type: Text
Name: Pet Feeeder Schedules 1, 2 and 3.
Result: input_text_pet_feeder_schedules_1, 2 and 3

2. Upload JS Script

Create a file called pet-feeder-card.js in the config\www folder. You can use Studio Code Server or File Editor add-ons for this purpose. Then add this code and save it.

// Pet Feeder Card for Aqara Pet Feeder (via Zigbee)
// (CC) 2025 - Fernando Santos

class PetFeederCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.schedules = [];
    this.editingIndex = -1;
    this._initialized = false;
    this._lastSaveTime = null;
  }

  setConfig(config) {
    if (!config.entity) {
      throw new Error('Please define an entity');
    }
    this.config = config;
    this.entity = config.entity;
    this.scheduleEntity1 = 'input_text.pet_feeder_schedules_1';
    this.scheduleEntity2 = 'input_text.pet_feeder_schedules_2';
    this.scheduleEntity3 = 'input_text.pet_feeder_schedules_3';
    
    // Load initial schedules
    if (this._hass) {
      this.loadInitialSchedules();
    }
  }

  set hass(hass) {
    const oldHass = this._hass;
    this._hass = hass;
    
    // Only load schedules on first initialization
    if (!this._initialized) {
      this.loadInitialSchedules();
      this._initialized = true;
      
      // Set up periodic sync every 30 seconds to catch changes from other devices
      setInterval(() => {
        this.syncSchedules();
      }, 30000);
    }
  }

  syncSchedules() {
    // Don't sync if we recently saved (within last 5 seconds)
    if (this._lastSaveTime && (Date.now() - this._lastSaveTime) < 5000) {
      return;
    }
    
    // Decompress function
    const decompressSchedule = (sched) => {
      if (sched.time && sched.portions && sched.days) {
        return sched;
      } else if (sched.t && sched.p && sched.d) {
        const dayMap = {m:'mon',t:'tue',w:'wed',h:'thu',f:'fri',s:'sat',u:'sun'};
        return {
          time: sched.t,
          portions: sched.p,
          days: sched.d.split('').map(letter => dayMap[letter])
        };
      }
      return sched;
    };
    
    // Reload schedules from entities to sync with other devices
    let loadedSchedules = [];
    
    [this.scheduleEntity1, this.scheduleEntity2, this.scheduleEntity3].forEach(entity => {
      const scheduleState = this._hass.states[entity];
      if (scheduleState && scheduleState.state && scheduleState.state !== 'unknown' && scheduleState.state !== '' && scheduleState.state !== '[]') {
        try {
          const parsed = JSON.parse(scheduleState.state);
          if (Array.isArray(parsed) && parsed.length > 0) {
            const decompressed = parsed.map(decompressSchedule);
            loadedSchedules = loadedSchedules.concat(decompressed);
          }
        } catch (e) {
          // Ignore parse errors during sync
        }
      }
    });
    
    // Only update if loaded data has MORE schedules or is significantly different
    if (loadedSchedules.length >= this.schedules.length) {
      const currentJson = JSON.stringify(this.schedules.sort((a,b) => a.time.localeCompare(b.time)));
      const loadedJson = JSON.stringify(loadedSchedules.sort((a,b) => a.time.localeCompare(b.time)));
      
      if (currentJson !== loadedJson) {
        this.schedules = loadedSchedules;
        this.updateScheduleList();
        this.updateHeader();
      }
    }
  }

  loadInitialSchedules() {
    let loadedSchedules = [];
    
    // Decompress function: convert single letters back to full day names
    const decompressSchedule = (sched) => {
      // Handle both old format and new compressed format
      if (sched.time && sched.portions && sched.days) {
        // Old uncompressed format
        return sched;
      } else if (sched.t && sched.p && sched.d) {
        // New compressed format
        const dayMap = {m:'mon',t:'tue',w:'wed',h:'thu',f:'fri',s:'sat',u:'sun'};
        return {
          time: sched.t,
          portions: sched.p,
          days: sched.d.split('').map(letter => dayMap[letter])
        };
      }
      return sched;
    };
    
    // Load from all three entities
    [this.scheduleEntity1, this.scheduleEntity2, this.scheduleEntity3].forEach(entity => {
      const scheduleState = this._hass.states[entity];
      
      if (scheduleState && scheduleState.state && scheduleState.state !== 'unknown' && scheduleState.state !== '' && scheduleState.state !== '[]') {
        try {
          const parsed = JSON.parse(scheduleState.state);
          if (Array.isArray(parsed) && parsed.length > 0) {
            const decompressed = parsed.map(decompressSchedule);
            loadedSchedules = loadedSchedules.concat(decompressed);
          }
        } catch (e) {
          console.error(`Failed to parse schedules from ${entity}:`, e);
        }
      }
    });
    
    // Only update schedules if we actually loaded something
    if (loadedSchedules.length > 0) {
      this.schedules = loadedSchedules;
    }
    
    this.render();
  }

  async saveSchedules() {
    // Record when we're saving to prevent sync from overwriting
    this._lastSaveTime = Date.now();
    
    console.log('=============================');
    console.log('PET FEEDER: SAVING SCHEDULES');
    console.log('=============================');
    console.log('Total schedules to save:', this.schedules.length);
    
    // CRITICAL: Never save if we're trying to save empty array and entities have data
    if (this.schedules.length === 0) {
      let hasExistingSchedules = false;
      [this.scheduleEntity1, this.scheduleEntity2, this.scheduleEntity3].forEach(entity => {
        const state = this._hass.states[entity];
        if (state && state.state && state.state !== 'unknown' && state.state !== '' && state.state !== '[]') {
          try {
            const parsed = JSON.parse(state.state);
            if (Array.isArray(parsed) && parsed.length > 0) {
              hasExistingSchedules = true;
            }
          } catch (e) {
            // Ignore parse errors
          }
        }
      });
      
      if (hasExistingSchedules) {
        this.loadInitialSchedules();
        return;
      }
    }
    
    // Compress schedules to save space: convert days to single letters
    // mon->m, tue->t, wed->w, thu->h, fri->f, sat->s, sun->u
    const compressSchedule = (sched) => {
      const dayMap = {mon:'m',tue:'t',wed:'w',thu:'h',fri:'f',sat:'s',sun:'u'};
      return {
        t: sched.time,
        p: sched.portions,
        d: sched.days.map(day => dayMap[day]).join('')
      };
    };
    
    const compressedSchedules = this.schedules.map(compressSchedule);
    
    // With compression, each schedule is ~35 chars, so we can fit 7 per entity
    const chunk1 = compressedSchedules.slice(0, 7);
    const chunk2 = compressedSchedules.slice(7, 14);
    const chunk3 = compressedSchedules.slice(14, 20);
    
    try {
      await this._hass.callService('input_text', 'set_value', {
        entity_id: this.scheduleEntity1,
        value: JSON.stringify(chunk1)
      });
      
      await this._hass.callService('input_text', 'set_value', {
        entity_id: this.scheduleEntity2,
        value: JSON.stringify(chunk2)
      });
      
      await this._hass.callService('input_text', 'set_value', {
        entity_id: this.scheduleEntity3,
        value: JSON.stringify(chunk3)
      });
      
      this.showMessage('Horario guardado correctamente.', 'success');
    } catch (e) {
      console.error('Failed to save schedules:', e);
      this.showMessage('Error al guardar el horario.', 'error');
    }
  }

  showMessage(message, type = 'info') {
    const event = new Event('hass-notification', {
      bubbles: true,
      composed: true,
    });
    event.detail = { message };
    this.dispatchEvent(event);
  }

  async manualFeed() {
    const portions = this.shadowRoot.getElementById('manual-portions').value;
    
    try {
      // ZNCWWSQ01LM requires setting serving_size first, then triggering feed
      // First, set the serving size
      await this._hass.callService('mqtt', 'publish', {
        topic: `zigbee2mqtt/${this.config.device_name || 'Aqara Pet Feeder'}/set`,
        payload: JSON.stringify({ serving_size: parseInt(portions) })
      });
      
      // Wait a moment for it to register
      await new Promise(resolve => setTimeout(resolve, 500));
      
      // Then trigger the feed
      await this._hass.callService('mqtt', 'publish', {
        topic: `zigbee2mqtt/${this.config.device_name || 'Aqara Pet Feeder'}/set`,
        payload: JSON.stringify({ feed: 'START' })
      });
      
      this.showMessage(`Erogando ${portions} porci${portions > 1 ? 'ones.' : 'ón.'}`, 'success');
    } catch (e) {
      console.error('Failed to feed:', e);
      this.showMessage('Error al activar la alimentación.', 'error');
    }
  }

  isDuplicate(newSchedule, excludeIndex = -1) {
    return this.schedules.some((schedule, index) => {
      if (index === excludeIndex) return false;
      // Only compare time and days, NOT portions
      return schedule.time === newSchedule.time &&
             JSON.stringify(schedule.days.sort()) === JSON.stringify(newSchedule.days.sort());
    });
  }

  addSchedule() {
    const time = this.shadowRoot.getElementById('schedule-time').value;
    const portions = parseInt(this.shadowRoot.getElementById('schedule-portions').value);
    const days = Array.from(this.shadowRoot.querySelectorAll('.day-checkbox:checked')).map(cb => cb.value);

    if (!time) {
      this.showMessage('Por favor selecciona una hora.', 'error');
      return;
    }

    if (days.length === 0) {
      this.showMessage('Por favor selecciona al menos un día.', 'error');
      return;
    }

    if (this.schedules.length >= 20 && this.editingIndex === -1) {
      this.showMessage('Máximo 20 horarios permitidos.', 'error');
      return;
    }

    const newSchedule = { time, portions, days: days.sort() };

    if (this.editingIndex >= 0) {
      if (this.isDuplicate(newSchedule, this.editingIndex)) {
        this.showMessage('Este horario ya existe.', 'error');
        return;
      }
      this.schedules[this.editingIndex] = newSchedule;
      this.editingIndex = -1;
      this.showMessage('Horario actualizado.', 'success');
    } else {
      if (this.isDuplicate(newSchedule)) {
        this.showMessage('Este horario ya existe.', 'error');
        return;
      }
      this.schedules.push(newSchedule);
      this.showMessage('Horario agregado.', 'success');
    }

    this.saveSchedules();
    this.clearForm();
    this.updateScheduleList();
    this.updateHeader();
  }

  editSchedule(index) {
    const schedule = this.schedules[index];
    this.editingIndex = index;
    
    const timeInput = this.shadowRoot.getElementById('schedule-time');
    const portionsInput = this.shadowRoot.getElementById('schedule-portions');
    
    if (timeInput) timeInput.value = schedule.time;
    if (portionsInput) portionsInput.value = schedule.portions;
    
    this.shadowRoot.querySelectorAll('.day-checkbox').forEach(cb => {
      const shouldBeChecked = schedule.days.includes(cb.value);
      cb.checked = shouldBeChecked;
      const label = cb.closest('.day-checkbox-label');
      if (shouldBeChecked) {
        label.classList.add('checked');
      } else {
        label.classList.remove('checked');
      }
    });
    
    const addBtn = this.shadowRoot.getElementById('add-schedule-btn');
    const cancelBtn = this.shadowRoot.getElementById('cancel-edit-btn');
    
    if (addBtn) addBtn.textContent = 'Actualizar Horario';
    if (cancelBtn) cancelBtn.style.display = 'inline-block';
  }

  cancelEdit() {
    this.editingIndex = -1;
    this.clearForm();
    
    const addBtn = this.shadowRoot.getElementById('add-schedule-btn');
    const cancelBtn = this.shadowRoot.getElementById('cancel-edit-btn');
    
    if (addBtn) addBtn.textContent = 'Agregar Horario';
    if (cancelBtn) cancelBtn.style.display = 'none';
  }

  deleteSchedule(index) {
    this.schedules.splice(index, 1);
    this.saveSchedules();
    this.showMessage('Horario eliminado.', 'success');
    this.updateScheduleList();
    this.updateHeader();
  }

  clearAllSchedules() {
    if (confirm('¿Estás seguro de que quieres limpiar todos los horarios?')) {
      this.schedules = [];
      
      try {
        // Clear all three entities directly
        this._hass.callService('input_text', 'set_value', {
          entity_id: this.scheduleEntity1,
          value: '[]'
        });
        
        this._hass.callService('input_text', 'set_value', {
          entity_id: this.scheduleEntity2,
          value: '[]'
        });
        
        this._hass.callService('input_text', 'set_value', {
          entity_id: this.scheduleEntity3,
          value: '[]'
        });
        
        this.showMessage('Todos los horarios eliminados.', 'success');
        this.updateScheduleList();
        this.updateHeader();
      } catch (e) {
        console.error('Error clearing schedules:', e);
        this.showMessage('Error al limpiar horarios.', 'error');
      }
    }
  }

  clearForm() {
    if (this.shadowRoot.getElementById('schedule-time')) {
      this.shadowRoot.getElementById('schedule-time').value = '';
    }
    if (this.shadowRoot.getElementById('schedule-portions')) {
      this.shadowRoot.getElementById('schedule-portions').value = '1';
    }
    this.shadowRoot.querySelectorAll('.day-checkbox').forEach(cb => {
      cb.checked = false;
      cb.closest('.day-checkbox-label').classList.remove('checked');
    });
  }

  formatDays(days) {
    const dayNames = { mon: 'Lun', tue: 'Mar', wed: 'Mié', thu: 'Jue', fri: 'Vie', sat: 'Sáb', sun: 'Dom' };
    if (days.length === 7) return 'Todos los Días';
    if (days.length === 5 && !days.includes('sat') && !days.includes('sun')) return 'Entre Semana';
    if (days.length === 2 && days.includes('sat') && days.includes('sun')) return 'Fines de Semana';
    return days.map(d => dayNames[d]).join(', ');
  }

  updateHeader() {
    const countBadge = this.shadowRoot.querySelector('.count-badge');
    if (countBadge) {
      countBadge.textContent = `${this.schedules.length}/20`;
    }
  }

  updateScheduleList() {
    const scheduleListContainer = this.shadowRoot.querySelector('#schedule-list-container');
    if (!scheduleListContainer) return;

    const sortedSchedules = [...this.schedules].sort((a, b) => {
      if (a.time !== b.time) return a.time.localeCompare(b.time);
      return a.days.join(',').localeCompare(b.days.join(','));
    });

    scheduleListContainer.innerHTML = `
      <div class="section-title">
        Horarios
        ${this.schedules.length > 0 ? `
          <button class="btn-danger btn-small" style="float: right;" onclick="this.getRootNode().host.clearAllSchedules()">
            Limpiar Todo
          </button>
        ` : ''}
      </div>
      
      ${sortedSchedules.length === 0 ? `
        <div class="empty-state">
          No hay horarios configurados. Agrega tu primer horario de alimentación arriba.
        </div>
      ` : `
        <div class="schedule-list">
          ${sortedSchedules.map((schedule) => {
            // Find the ACTUAL index in the unsorted array
            const actualIndex = this.schedules.findIndex(s => 
              s === schedule
            );
            return `
              <div class="schedule-item">
                <div class="schedule-info">
                  <div class="schedule-time">${schedule.time}</div>
                  <div class="schedule-details">
                    ${schedule.portions} porción${schedule.portions > 1 ? 'es' : ''} • ${this.formatDays(schedule.days)}
                  </div>
                </div>
                <div class="schedule-actions">
                  <button class="btn-secondary btn-small" onclick="this.getRootNode().host.editSchedule(${actualIndex})">
                    Editar
                  </button>
                  <button class="btn-danger btn-small" onclick="this.getRootNode().host.deleteSchedule(${actualIndex})">
                    Eliminar
                  </button>
                </div>
              </div>
            `;
          }).join('')}
        </div>
      `}
    `;
  }

  render() {
    if (!this._hass) return;

    const sortedSchedules = [...this.schedules].sort((a, b) => {
      if (a.time !== b.time) return a.time.localeCompare(b.time);
      return a.days.join(',').localeCompare(b.days.join(','));
    });

    this.shadowRoot.innerHTML = `
      <style>
        ha-card {
          padding: 16px;
        }
        .header {
          display: flex;
          justify-content: space-between;
          align-items: center;
          margin-bottom: 16px;
        }
        .title {
          font-size: 16px;
          font-weight: 500;
          display: flex;
          align-items: center;
          gap: 12px;
        }
        .logo {
          width: 83px;
          height: 24px;
          object-fit: contain;
        }
        .section {
          margin-bottom: 24px;
          padding: 16px;
          background: var(--primary-background-color);
          border-radius: 8px;
        }
        .section-title {
          font-size: 16px;
          font-weight: 500;
          margin-bottom: 12px;
          color: var(--primary-text-color);
        }
        .form-row {
          display: flex;
          gap: 12px;
          margin-bottom: 12px;
          align-items: center;
          flex-wrap: wrap;
        }
        .form-group {
          display: flex;
          flex-direction: column;
          flex: 1;
          min-width: 120px;
        }
        label {
          font-size: 14px;
          margin-bottom: 4px;
          color: var(--secondary-text-color);
        }
        input[type="time"],
        input[type="number"],
        select {
          padding: 8px;
          border: 1px solid var(--divider-color);
          border-radius: 4px;
          background: var(--card-background-color);
          color: var(--primary-text-color);
          font-size: 14px;
        }
        .days-selector {
          display: flex;
          gap: 8px;
          flex-wrap: wrap;
          margin-bottom: 12px;
        }
        .day-checkbox-label {
          display: flex;
          align-items: center;
          gap: 4px;
          padding: 6px 12px;
          border: 1px solid var(--divider-color);
          border-radius: 16px;
          cursor: pointer;
          user-select: none;
          transition: all 0.2s;
        }
        .day-checkbox-label:hover {
          background: var(--secondary-background-color);
        }
        .day-checkbox-label.checked {
          background: var(--primary-color);
          color: white;
          border-color: var(--primary-color);
        }
        .day-checkbox {
          display: none;
        }
        button {
          padding: 10px 20px;
          border: none;
          border-radius: 4px;
          cursor: pointer;
          font-size: 14px;
          font-weight: 500;
          transition: all 0.2s;
        }
        .btn-primary {
          background: var(--primary-color);
          color: white;
        }
        .btn-primary:hover {
          opacity: 0.9;
        }
        .btn-secondary {
          background: var(--secondary-background-color);
          color: var(--primary-text-color);
        }
        .btn-secondary:hover {
          background: var(--divider-color);
        }
        .btn-danger {
          background: var(--error-color);
          color: white;
        }
        .btn-danger:hover {
          opacity: 0.9;
        }
        .btn-small {
          padding: 6px 12px;
          font-size: 12px;
        }
        .schedule-list {
          display: flex;
          flex-direction: column;
          gap: 8px;
        }
        .schedule-item {
          display: flex;
          justify-content: space-between;
          align-items: center;
          padding: 12px;
          background: var(--card-background-color);
          border: 1px solid var(--divider-color);
          border-radius: 8px;
        }
        .schedule-info {
          flex: 1;
        }
        .schedule-time {
          font-size: 18px;
          font-weight: 500;
          margin-bottom: 4px;
        }
        .schedule-details {
          font-size: 11px;
          color: var(--secondary-text-color);
        }
        .schedule-actions {
          display: flex;
          gap: 8px;
        }
        .manual-feed {
          display: flex;
          gap: 12px;
          align-items: flex-end;
        }
        .empty-state {
          text-align: center;
          padding: 24px;
          color: var(--secondary-text-color);
        }
        .button-row {
          display: flex;
          gap: 8px;
          flex-wrap: wrap;
        }
        #cancel-edit-btn {
          display: none;
        }
        .count-badge {
          display: inline-block;
          background: var(--primary-color);
          color: white;
          padding: 2px 8px;
          border-radius: 12px;
          font-size: 12px;
          font-weight: 500;
          margin-left: 8px;
        }
      </style>

      <ha-card>
        <div class="header">
          <div class="title">
            <img src="https://www.aqara.com/wp-content/uploads/2023/05/aqara-logo.png" class="logo" alt="Aqara">
            Pet Feeder
            <span class="count-badge">${this.schedules.length}/20</span>
          </div>
        </div>

        <div class="section">
          <div class="section-title">Alimentación Manual</div>
          <div class="manual-feed">
            <div class="form-group">
              <label>Porciones</label>
              <input type="number" id="manual-portions" min="1" max="10" value="1">
            </div>
            <button class="btn-primary" onclick="this.getRootNode().host.manualFeed()">
              Alimentar Ahora
            </button>
          </div>
        </div>

        <div class="section">
          <div class="section-title">${this.editingIndex >= 0 ? 'Editar Horario' : 'Agregar Horario'}</div>
          
          <div class="form-row">
            <div class="form-group">
              <label>Hora</label>
              <input type="time" id="schedule-time">
            </div>
            <div class="form-group">
              <label>Porciones</label>
              <input type="number" id="schedule-portions" min="1" max="10" value="1">
            </div>
          </div>

          <div class="form-group">
            <label>Días</label>
            <div class="days-selector">
              ${['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map(day => {
                const dayNames = {mon: 'Lun', tue: 'Mar', wed: 'Mié', thu: 'Jue', fri: 'Vie', sat: 'Sáb', sun: 'Dom'};
                return `
                <label class="day-checkbox-label">
                  <input type="checkbox" class="day-checkbox" value="${day}">
                  ${dayNames[day]}
                </label>
              `}).join('')}
            </div>
          </div>

          <div class="button-row">
            <button id="add-schedule-btn" class="btn-primary" onclick="this.getRootNode().host.addSchedule()">
              Agregar Horario
            </button>
            <button id="cancel-edit-btn" class="btn-secondary" onclick="this.getRootNode().host.cancelEdit()">
              Cancelar
            </button>
          </div>
        </div>

        <div class="section" id="schedule-list-container">
          <div class="section-title">
            Horarios
            ${this.schedules.length > 0 ? `
              <button class="btn-danger btn-small" style="float: right;" onclick="this.getRootNode().host.clearAllSchedules()">
                Limpiar Todo
              </button>
            ` : ''}
          </div>
          
          ${sortedSchedules.length === 0 ? `
            <div class="empty-state">
              No hay horarios configurados. Agrega tu primer horario de alimentación arriba.
            </div>
          ` : `
            <div class="schedule-list">
              ${sortedSchedules.map((schedule) => {
                const actualIndex = this.schedules.findIndex(s => 
                  s.time === schedule.time && 
                  s.portions === schedule.portions && 
                  JSON.stringify(s.days) === JSON.stringify(schedule.days)
                );
                return `
                  <div class="schedule-item">
                    <div class="schedule-info">
                      <div class="schedule-time">${schedule.time}</div>
                      <div class="schedule-details">
                        ${schedule.portions} Porci${schedule.portions > 1 ? 'ones' : 'ón'}<br>
                        ${this.formatDays(schedule.days)}
                      </div>
                    </div>
                    <div class="schedule-actions">
                      <button class="btn-secondary btn-small" onclick="this.getRootNode().host.editSchedule(${actualIndex})">
                        Editar
                      </button>
                      <button class="btn-danger btn-small" onclick="this.getRootNode().host.deleteSchedule(${actualIndex})">
                        Eliminar
                      </button>
                    </div>
                  </div>
                `;
              }).join('')}
            </div>
          `}
        </div>
      </ha-card>
    `;

    // Add event listeners for day checkboxes
    this.shadowRoot.querySelectorAll('.day-checkbox-label').forEach(label => {
      label.addEventListener('click', (e) => {
        const checkbox = label.querySelector('.day-checkbox');
        checkbox.checked = !checkbox.checked;
        if (checkbox.checked) {
          label.classList.add('checked');
        } else {
          label.classList.remove('checked');
        }
        e.preventDefault();
      });
    });
  }

  getCardSize() {
    return 9;
  }
}

customElements.define('pet-feeder-card', PetFeederCard);

window.customCards = window.customCards || [];
window.customCards.push({
  type: 'pet-feeder-card',
  name: 'Pet Feeder Card',
  description: 'A card for managing pet feeder schedules'
});

3. Load Script as Dashboard Card

Go to Settings > Dashboard > … > Resources > Add Resource

Complete with these details:
URL: /local/pet-feeder-card.js?v=1
Type: JavaScript Module

Note: the ?v=1 modifier in the URL allows you to create increments if the JS script is modified.

4. Add Automation

Create a new automation and add the following YAML code.

alias: Pet Feeder - Erogación Programada
description: Automatically feed pet. Compatible with Aqara C1 via Zigbee2MQTT.
triggers:
  - minutes: "*"
    trigger: time_pattern
conditions:
  - condition: template
    value_template: "{{ all_raw | length > 0 }}"
actions:
  - repeat:
      for_each: "{{ all_raw }}"
      sequence:
        - variables:
            schedule_time: >
              {% if repeat.item.t is defined %}{{ repeat.item.t }} {% elif
              repeat.item.time is defined %}{{ repeat.item.time }} {% else
              %}unknown{% endif %}
            schedule_portions: >
              {% if repeat.item.p is defined %}{{ repeat.item.p }} {% elif
              repeat.item.portions is defined %}{{ repeat.item.portions }} {%
              else %}1{% endif %}
            schedule_days: |
              {% if repeat.item.d is defined %}
                {# Compressed format - decompress the day string #}
                {% set day_str = repeat.item.d %}
                {% set result = [] %}
                {% if 'm' in day_str %}{% set result = result + ['mon'] %}{% endif %}
                {% if 't' in day_str %}{% set result = result + ['tue'] %}{% endif %}
                {% if 'w' in day_str %}{% set result = result + ['wed'] %}{% endif %}
                {% if 'h' in day_str %}{% set result = result + ['thu'] %}{% endif %}
                {% if 'f' in day_str %}{% set result = result + ['fri'] %}{% endif %}
                {% if 's' in day_str %}{% set result = result + ['sat'] %}{% endif %}
                {% if 'u' in day_str %}{% set result = result + ['sun'] %}{% endif %}
                {{ result }}
              {% elif repeat.item.days is defined %}
                {# Old uncompressed format #}
                {{ repeat.item.days }}
              {% else %}
                []
              {% endif %}
        - condition: template
          value_template: |
            {{ schedule_time == current_time and current_day in schedule_days }}
        - data:
            topic: zigbee2mqtt/Aqara Pet Feeder/set
            payload: |
              {"serving_size": {{ schedule_portions }}}
          action: mqtt.publish
        - delay:
            milliseconds: 500
        - data:
            topic: zigbee2mqtt/Aqara Pet Feeder/set
            payload: "{\"feed\": \"START\"}"
          action: mqtt.publish
variables:
  current_time: "{{ now().strftime('%H:%M') }}"
  current_day: "{{ now().strftime('%a').lower()[:3] }}"
  schedules1_raw: >
    {% set s1 = states('input_text.pet_feeder_schedules_1') %} {% if s1 not in
    ['unknown', '', '[]'] %}{{ s1 | from_json }}{% else %}[]{% endif %}
  schedules2_raw: >
    {% set s2 = states('input_text.pet_feeder_schedules_2') %} {% if s2 not in
    ['unknown', '', '[]'] %}{{ s2 | from_json }}{% else %}[]{% endif %}
  schedules3_raw: >
    {% set s3 = states('input_text.pet_feeder_schedules_3') %} {% if s3 not in
    ['unknown', '', '[]'] %}{{ s3 | from_json }}{% else %}[]{% endif %}
  all_raw: "{{ schedules1_raw + schedules2_raw + schedules3_raw }}"
mode: single

5. Restart Home Assistant

6. Add Card

Add the card to your dashboard by looking for the new Pet Feeder Card and then completing the name of your Zigbee Pet Feeder. Use the YAML code below (my feeder’s name is ‘Aqara Pet Feeder’):

type: custom:pet-feeder-card
entity: sensor.pet_feeder_schedule_tracker
device_name: Aqara Pet Feeder

Some screenshots. Again, my UI is in Spanish but it’s easily editable.

1 Like